cyberine
UsageRun recipes

Wait for / digest a background run — cyberine wait, cyberine digest

A background run (cyberine run --bg, chat /run, npm run wf) prints a runId. Two commands turn that id into a hands-off operator loop without polling run.json by hand:

cyberine wait <runId>          # block until the run reaches a terminal state, then print it + exit
cyberine wait <runId> && cyberine apply <runId>   # apply the winner only once the run finished cleanly
cyberine digest <runId>        # compact one-screen digest: state + verdict + winner + diffstat + summary
cyberine digest <runId> --json # machine-readable status oracle (one JSON line + exit code)

cyberine wait is event-driven: it listens on the global run-lifecycle bus channel for the run's run:completed envelope (falling back to a run.json re-check when the bus is disabled), so it returns the instant the run ends. By default it has NO wait ceiling and follows the run to a terminal state, however long that takes (kf11q — a multi-step implement-task run can outlive a short default and leave the caller thinking it is still going when it already finished). Set CYBERINE_WAIT_TIMEOUT_MS (ms) to opt into an explicit ceiling; unset or a non-positive value means "no ceiling". So that "no ceiling" never means "forever", wait also watches the run's recorded engine pid: when the engine process is gone on two consecutive polls while the run is still non-terminal (a crashed or killed engine that never wrote its end state), it exits 1 with the engine (pid N) for <runId> is gone and the run never reached a terminal state and the path of the run's console.log. cyberine land's wait loop does the same and reports the run as not landed. A run with no recorded engine pid (still launching) is not checked.

It reports FOUR distinguishable outcomes, because "non-zero" is not one fact:

ExitMeaningWhat a caller should do
0the run finished (done)proceed — cyberine wait <id> && cyberine apply <id> is safe
1the RUN failed (error / cancelled)read the run; it is over
124timeout — the CALLER set CYBERINE_WAIT_TIMEOUT_MS and it expired; the run is still goingre-arm the watch, or raise/unset CYBERINE_WAIT_TIMEOUT_MS
125the WATCHER died (killed, or an uncaught throw) — the run's state is UNKNOWNinvestigate the run; do NOT treat it as dead (kr40v)

The 124/125 split matters because the two call for opposite reactions. Before kr40v a watcher that crashed exited 1, so a healthy run read as a failed one — measured 2026-09-12, when a peer session lost a real diagnosis to exactly that, and only a stack trace naming an unrelated repo revealed the watcher had died rather than the run. Both the timeout line and the crash line name the run's last known state on stderr, and the crash line says state is UNKNOWN, not failed in as many words.

Inside a Claude Code session (ktq9j, 2026-10-01). Claude Code 2.1.285 stops a background Bash command when its timeout runs out (default 30 minutes; the ceiling is 2 hours unless BASH_MAX_TIMEOUT_MS is set higher, per the 2.1.287 binary). A backgrounded cyberine wait that the harness stops exits 125 with the watcher was killed by SIGTERM, while the run itself keeps going as its own detached process. The /cyberine:run skill therefore launches the watch with a 2 hour background timeout, which every host accepts, and caps it just under that, CYBERINE_WAIT_TIMEOUT_MS=7000000 cyberine wait <runId> && cyberine digest <runId>, so a long run ends the watch with 124 and a known state; the session then launches the same line again, which returns at once if the run finished in between. If Claude Code refuses to start the background command at all (its result says nothing was started, for example an execution target already at its per-session limit of background commands; that limit belongs to the target, not to local Bash), no watch runs and no notification comes: launch the line again later or run it in the foreground; the run itself is unaffected. Outside Claude Code nothing changes: leave the variable unset.

Verified by npm run selftest:wait-exit-codes, which drives the real CLI for all four codes: the crash is a genuine uncaught exception injected with --require, the kill is a real SIGTERM, and the controls assert that a failed run still exits 1 and never claims the state is unknown.

Known limitations and deferred work. 125 covers a watcher that dies while it is watching. It cannot cover a process that never got that far — a SIGKILL, an OOM, or a module-load failure leaves no handler to run (the last of those is what cyberine build removes, see "The built artifact"). Only wait polls a run, so only wait carries these codes; apply / digest / land keep their own. With no CYBERINE_WAIT_TIMEOUT_MS set, wait blocks for as long as the run takes — there is still no ceiling derived from the run's own step timeouts (e.g. summing a workflow's per-step LOOP_MAX_TOOL_ROUNDS/timeout budget); an explicit CYBERINE_WAIT_TIMEOUT_MS is the only way to bound it today. cyberine land's own wait loop (used when a passed-in run is not yet terminal) follows the same no-default-ceiling rule but has no distinct timeout exit code of its own — a timeout there is reported and folded into land's existing "not landed" exit 1. cyberine digest never prints the raw diff — only the file-level diffstat (git apply --stat) plus the judge verdict and the extracted accomplished/next/issues summary, ideal for a quick "should I apply?" read or for piping into another tool. Both resolve a runId prefix locally first, then via the global registry (same lookup as cyberine apply).

Machine-readable oracle — cyberine digest <runId> --json

--json emits exactly one JSON line to stdout and exits with a status-aligned exit code. It is the compact oracle that lets a controlling agent (Claude Code, opencode, a shell script) branch deterministically without ingesting the full human-readable transcript:

{"runId":"…","state":"done","status":"ok","winnerAgentId":"loop-deepseek-build","diffFiles":["src/foo.ts"],"verifyPassed":true,"rationale":"…"}

Status taxonomy and exit codes:

statusMeaningExit code
okRun finished, winner diff exists, trust-gate passed (or no gate ran)0
noopRun finished but produced a text answer, not a code diff0
check_failRun finished but trust-gate rejected the diff (apply, typecheck, verifyCmd, or adversarial)3
hard_failRun terminated in error or was cancelled3
transientRun is still in progress (not yet in a terminal state)2

Fields:

FieldTypeDescription
runIdstringFull run UUID
statestringRaw run.state value (done, error, cancelled, running, queued)
statusstringStable status enum (see table above) — branch on this, not state
winnerAgentIdstring | nullAgent id of the judged winner, or null when none
diffFilesstring[]Sorted list of files touched by the winning diff
verifyPassedboolean | nullTrue/false when a trust-gate ran; null when no gate ran or it was off
rationalestringHuman-readable explanation drawn from the verdict or trust-gate reason
reviewsobject[]Each parsed review, first review first: { stepId, verdict, risk, concerns }. Two entries when a review-fix pass was adopted and re-reviewed (kd8dj)
reviewFixobject | null{ outcome, detail, implementer } of the review-fix pass, or null when none was considered

Typical controller pattern:

cyberine wait "$RUN_ID"
result=$(cyberine digest "$RUN_ID" --json)
status=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['status'])")
case "$status" in
  ok)   cyberine apply "$RUN_ID" --commit ;;
  noop) echo "text answer — no diff to apply" ;;
  *)    echo "run did not pass gates: $status" ; exit 1 ;;
esac