cyberine
UsageRun recipes

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-dir and friends are variadic and swallow a trailing positional. The run id comes from CYBERINE_RUN_ID, else the cyberine-<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> (bgWorkerRowName reuses fleetRowName's guard and additionally prefixes an all-digit slot with s).
  • 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" in claude --help or reads a result event a background session never emits.
  • Turn boundary and measurement come from claude agents --json --all (state) plus the session transcript's own system/turn_duration record, read through the kddqh helpers in src/autopilot/bgSessionDriver.ts — the same code the autopilot bg driver uses, not a copy. The cwd is realpath-resolved before deriving the transcript slug. Usage.inputTokens / outputTokens / cacheHitTokens are the turn totals; Result.raw.contextTokens is the LAST top-level assistant record's input + 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 runs claude rm — removal is the operator's call, and the stopped row stays openable with claude attach <id> and resumable with claude --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 report cancelled with no bgJobId and 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 with FAKE_BG_BANNER_DELAY_MS instead 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-worker

Known Limitations and Deferred Work (CYBERINE_CLAUDE_WORKER_MODE=bg).

  • An untrusted cwd refuses (kz74p). Since claude 2.1.281, claude --bg in a directory that never passed the workspace trust prompt exits 1 with Workspace not trusted. ... when not run interactively. The bg worker and the autopilot --driver bg surface that as BgSessionDriverError code untrusted-cwd, naming the directory; the fix is to run claude there (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 result event, so there is no total_cost_usd (verified on a live job's transcript: the string appears nowhere in the file). Usage.costUsd is undefined rather 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 (aggregateRunUsage and summarizeCostLedger carry a sawCost flag; cyberine cost prints a dim —), and the spend cap is unaffected because it only records realized spend for loop adapters. The residual: a day or provider row that MIXES bg and -p workers under-reports by the bg worker's share while still printing a number. The bg worker therefore carries its own provider label claude-bg:<model> so it lands in its own ledger row instead of deflating claude:<model>. Consequence of THAT: a per-provider token budget configured for the prefix claude (ConcurrencyGovernor) does not cover claude-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 --bg client'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.sandbox reports none (the run detail view renders [sb:none] in the warn colour), and CYBERINE_TASK_TRUSTED=false makes the adapter refuse with a HostSandboxError naming bg mode instead of running unconfined. Use the default -p worker 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=1 accepts 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.
  • envSet rides in --settings {"env":{...}}, and only SETTING is proven. Live-verified with a control: CLAUDE_CODE_DISABLE_BUNDLED_SKILLS and CLAUDE_CODE_RETRY_WATCHDOG both arrived in the worker, while a var in neither --settings nor 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 --settings would REPLACE the first, so the bashOutputMaxChars cap and the env block share one JSON object.
  • --model and 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.model reports what actually answered.
  • No Needs input state and no tool-level progress. The adapter emits started, periodic heartbeats naming the row, then done/error — the transcript's tool_use records are not mapped to tool progress events the way -p stream-json events are, so the TUI shows liveness but not which tool is running. Peek at the row with claude agents for that.
  • An unapproved project MCP server blocks the job instead of failing it. A first --bg run in a directory whose .mcp.json has never been trusted parks at blocked with 1 new MCP server needs approval, and --permission-prompts none does not cover that prompt. It surfaces as a timeout naming the job state; claude logs <id> shows what it is waiting on. --safe-mode is passed, which disables MCP servers for the session, so this is unlikely rather than impossible.
  • A turn left unfinished at blocked fails with needs-input (kakt9). A mid-turn AskUserQuestion parks the job at blocked (measured on 2.1.287); the worker now returns an error naming claude attach <id> instead of reporting the half-finished turn as a success.
  • --exec/--bg job lifecycle is undocumented in claude --help. --bg itself is documented; the claude agents --json --all row shape this worker reads (sessionId, state, pid) is the 2.1.263 surface kddqh verified. CLI_FLAG_COMPAT gates it at that version so an older binary gets the named T656 diagnosis rather than an opaque failure.
  • Only the claude worker 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 for claude.