Background fleet worker — claude --bg (k56eq)
The default claude build worker is an opaque claude -p child: nothing can open it, steer it, or outlive the orchestrator that spawned it. CYBERINECLAUDEWORKERMODE=bg swaps that ONE worker for a...
The default claude build worker is an opaque claude -p child: nothing can open it, steer it, or
outlive the orchestrator that spawned it. CYBERINE_CLAUDE_WORKER_MODE=bg swaps that ONE worker for
a daemon-supervised background job — a real row in claude agents named
cyberine-w-<runId8>-<slot>, openable with claude attach, steerable from the fleet peek panel and
from claude.ai Remote Control, and surviving the orchestrator's death. Everything else about the run
is unchanged: same Task, same worktree, same judge, same Result shape.
It is OPT-IN and never the default. The daemon is not always up, --exec/--bg job lifecycle is
undocumented in --help, and a worker that lives seconds does not need a row. With the env var
unset, makeClaudeAdapter's -p argv and Result are byte-for-byte unchanged (asserted by the
smoke, both directions).
- Spawn:
claude "<prompt>" --bg --name cyberine-w-<runId8>-<slot> --model <m> --permission-prompts none --dangerously-skip-permissions --safe-mode --settings <json> --add-dir <task.cwd>. The prompt is argv[0] on purpose —--add-dirand friends are variadic and swallow a trailing positional. The run id comes fromCYBERINE_RUN_ID, else thecyberine-<runId8>-<agent>worktree directory name, else the task id; the slot defaults to the adapter id. The name can never end in_<digits>or-<3+ digits>(bgWorkerRowNamereusesfleetRowName's guard and additionally prefixes an all-digit slot withs). - Print-mode-only flags are NOT sent:
-p,--output-format stream-json --verbose,--fallback-model,--no-session-persistence,--forward-subagent-text,--json-schema. Each either says "only works with--print" inclaude --helpor reads aresultevent a background session never emits. - Turn boundary and measurement come from
claude agents --json --all(state) plus the session transcript's ownsystem/turn_durationrecord, read through the kddqh helpers insrc/autopilot/bgSessionDriver.ts— the same code the autopilot bg driver uses, not a copy. The cwd isrealpath-resolved before deriving the transcript slug.Usage.inputTokens/outputTokens/cacheHitTokensare the turn totals;Result.raw.contextTokensis the LAST top-levelassistantrecord'sinput + cache_read + cache_creation, with sidechain (subagent) records skipped. - Lifecycle: the adapter runs
claude stop <jobId>when the turn completes AND when the caller aborts, so nothing is ever left running. It never runsclaude rm— removal is the operator's call, and the stopped row stays openable withclaude attach <id>and resumable withclaude --resume <sessionId>. - An abort during the spawn window is covered too (k0ka5). The CLI registers the row before it
prints
backgrounded · <id>, so for a moment the driver holds a live job it cannot name. It used to reportcancelledwith nobgJobIdand stop nothing, orphaning that job under the daemon;close()now settles the in-flight spawn before deciding there is nothing to stop, and the id is adopted as soon as it is parsed rather than only after the session resolves. The smoke forces that window open deterministically withFAKE_BG_BANNER_DELAY_MSinstead of racing for it.
Run recipe:
# one dispatch whose claude worker is a fleet row instead of a -p child
CYBERINE_CLAUDE_WORKER_MODE=bg CYBERINE_BUILD_FLEET=claude cyberine run "add a health endpoint"
# in another terminal, while it works: the worker is a real row
claude agents --json --all | jq '.[] | select(.name | startswith("cyberine-w-"))'
claude agents # the TUI: arrows to the row, Space to peek, type a reply, Enter
claude logs <jobId> # what it is doing
claude attach <jobId> # take the terminal over
# what the run recorded (cost is absent by contract, sandbox is honestly 'none')
jq '.steps[].workerRuns[] | {agentId, provider, usage, sandbox}' .local/runs/<runId>/run.json
# argv, measurement, refusals and the -p byte-identity control, with no provider call
npm run selftest:bg-fleet-workerKnown Limitations and Deferred Work (CYBERINE_CLAUDE_WORKER_MODE=bg).
- An untrusted cwd refuses (kz74p). Since claude 2.1.281,
claude --bgin a directory that never passed the workspace trust prompt exits1withWorkspace not trusted. ...when not run interactively. The bg worker and the autopilot--driver bgsurface that asBgSessionDriverErrorcodeuntrusted-cwd, naming the directory; the fix is to runclaudethere (or in the repo that contains it) once and accept the prompt. Cyberine never pre-trusts a directory for you. - No cost, ever. A background session emits no
resultevent, so there is nototal_cost_usd(verified on a live job's transcript: the string appears nowhere in the file).Usage.costUsdisundefinedrather than derived — the repo's price table models neither cache-read nor cache-creation rates, and a wrong number is worse than none. Every reader already tolerates this (aggregateRunUsageandsummarizeCostLedgercarry asawCostflag;cyberine costprints a dim—), and the spend cap is unaffected because it only records realized spend forloopadapters. The residual: a day or provider row that MIXES bg and-pworkers under-reports by the bg worker's share while still printing a number. The bg worker therefore carries its own provider labelclaude-bg:<model>so it lands in its own ledger row instead of deflatingclaude:<model>. Consequence of THAT: a per-provider token budget configured for the prefixclaude(ConcurrencyGovernor) does not coverclaude-bg. Provider budgets default empty, so this is inert unless you configured one. - No host sandbox — and a mandatory one REFUSES. Measured 2026-09-08 (claude 2.1.263): a var
added only to the
claude --bgclient's environment never reaches the job, and a var DELETED from it still does — the worker runs under the daemon, not as a child of the spawn Cyberine wraps. A Seatbelt/bubblewrap profile would confine only the client, which exits immediately. So the bg worker is never wrapped,Result.raw.sandboxreportsnone(the run detail view renders[sb:none]in the warn colour), andCYBERINE_TASK_TRUSTED=falsemakes the adapter refuse with aHostSandboxErrornaming bg mode instead of running unconfined. Use the default-pworker for an untrusted task. - The metered-key scrub cannot be applied, so it is enforced by refusal.
envScrub(ANTHROPIC_API_KEY, whenever a Claude subscription exists) exists to keep the worker on the flat subscription; the daemon owns the worker's env, so Cyberine cannot remove it there. If the key is set in the orchestrator's own environment, the bg worker REFUSES to spawn, naming the key (never the value).CYBERINE_CLAUDE_BG_ALLOW_METERED_ENV=1accepts metered billing for background workers. Residual, unavoidable: when the key is absent from Cyberine's env we cannot prove it is absent from the daemon's, so a daemon started with a metered key in scope will still bill it. envSetrides in--settings {"env":{...}}, and only SETTING is proven. Live-verified with a control:CLAUDE_CODE_DISABLE_BUNDLED_SKILLSandCLAUDE_CODE_RETRY_WATCHDOGboth arrived in the worker, while a var in neither--settingsnor the client env did not. Whether an empty value can NEUTRALISE an inherited var is NOT measured, which is why the refusal above is not replaced by an empty-string trick. Note that a second--settingswould REPLACE the first, so thebashOutputMaxCharscap and the env block share one JSON object.--modeland every other option are frozen at spawn. A background session replays its own saved options and passing a flag on a resume forks a copy, so a bg worker cannot be re-modelled mid-run.Result.raw.modelreports what actually answered.- No
Needs inputstate and no tool-level progress. The adapter emitsstarted, periodicheartbeats naming the row, thendone/error— the transcript'stool_userecords are not mapped totoolprogress events the way-pstream-json events are, so the TUI shows liveness but not which tool is running. Peek at the row withclaude agentsfor that. - An unapproved project MCP server blocks the job instead of failing it. A first
--bgrun in a directory whose.mcp.jsonhas never been trusted parks atblockedwith1 new MCP server needs approval, and--permission-prompts nonedoes not cover that prompt. It surfaces as a timeout naming the job state;claude logs <id>shows what it is waiting on.--safe-modeis passed, which disables MCP servers for the session, so this is unlikely rather than impossible. - A turn left unfinished at
blockedfails withneeds-input(kakt9). A mid-turnAskUserQuestionparks the job atblocked(measured on 2.1.287); the worker now returns an error namingclaude attach <id>instead of reporting the half-finished turn as a success. --exec/--bgjob lifecycle is undocumented inclaude --help.--bgitself is documented; theclaude agents --json --allrow shape this worker reads (sessionId,state,pid) is the 2.1.263 surface kddqh verified.CLI_FLAG_COMPATgates it at that version so an older binary gets the named T656 diagnosis rather than an opaque failure.- Only the
claudeworker has a bg mode. Codex, Gemini, Muse and the loop workers are unaffected by the env var, and there is no per-worker override: the switch is fleet-wide forclaude.
Which Claude account a worker bills — oauthToken (k8rhz)
CLAUDECODEOAUTHTOKEN is a long-lived subscription credential that decides WHICH SEAT a claude worker bills. It used to reach every spawned worker by plain inheritance: T603's least-privilege env
Idle-stream watchdog — abort a stalled stream, fleet worker + chat (T562, T570)
Claude Code v2.1.196 made a streaming idle watchdog default-on: it aborts and retries a response stream that produces no events for 5 minutes. LoopAgentAdapter (fleet workers) AND the interactive...