Steering a running loop worker (between tool rounds)
control.json carries one of two actions — stop (above) or steer. A steer message injects
free-text operator guidance into a running worker between tool rounds for loop workers, or at
the next step boundary for one-shot CLI workers — never mid-generation and never aborting a
running HTTP stream.
Loop workers (LoopAgentAdapter — DeepSeek, OpenRouter, Kimi, etc.)
After each completed tool round, the adapter polls the engine's steer queue. If a steer arrived
since the last poll, it is injected as a user turn into the conversation context before the next
API call, so the model sees the guidance immediately and can change course in the same step.
Consumed steers are recorded in events.jsonl (kind: worker:steered, detail: the steer text)
and in run.json under steers[] for audit.
# From cyberine chat — steers the most recently dispatched run:
/steer focus on the migration path; skip UI polish
# Optional explicit runId (e.g. after /agents showed you another run):
/steer abc12345 rewrite the tests using the new fixturesMultiple steers queued before the next tool-round boundary are all injected in order.
The [Operator steering]: prefix labels each injected turn so the model can distinguish
operator guidance from normal tool results.
CLI workers (OpenCode, Claude Code, Codex — one-shot)
Bound a claude worker's bash-tool output (kqq2g) when a task runs noisy verifies (test suites,
greps) that would otherwise flood the worker's own context:
CYBERINE_CLAUDE_BASH_OUTPUT_MAX_CHARS=8000 cyberine run --fleet claude "run the test suite and fix the first failure"Known Limitations and Deferred Work: the cap is per-tool-call, not per-turn, and only the claude worker honours it (codex and muse have no equivalent setting wired); the default argv is unchanged when the variable is unset.
CYBERINE_CLAUDE_TASK_OUTPUT_MAX_CHARS (kb8rd) is RETIRED (khv8j, 2026-09-25): Claude Code 2.1.278 removed the TaskOutput tool and states that the taskOutputMaxChars setting and TASK_MAX_OUTPUT_LENGTH no longer have any effect, so Cyberine no longer reads the variable or forwards the key. Setting it does nothing. CYBERINE_CLAUDE_BASH_OUTPUT_MAX_CHARS above is unaffected.
Known Limitations and Deferred Work: two related v2.1.267 surfaces were reviewed in the kb8rd pass and
deliberately NOT adopted: --system-prompt-snapshot off re-renders the system prompt on every
request, which is a deliberate prompt-cache MISS and works against the cache-reuse the adapter
already buys with --exclude-dynamic-system-prompt-sections; and --append-subagent-system-prompt-file
does not appear in claude --help on 2.1.267, so it is treated as unverified rather than wired
from a changelog line. Also recorded, not fixed: v2.1.267's maxEffortLevel setting caps effort
across every provider, so a user-level or managed cap can silently lower a worker below
CYBERINE_CLAUDE_EFFORT with nothing visible to the adapter.
Known Limitations and Deferred Work -- a claude worker's Agent subagents do NOT inherit the task
contract (khvzt, measured on 2.1.263, transcripts under .local/tmp/khvzt/, write-up in
.local/research/claude-worker-subagent-contract-2026-09-07.md): neither --append-system-prompt
nor the --system-prompt the cyberine persona uses reaches a subagent; a --agents redefinition of
the built-in general-purpose type DOES reach it, but only without --safe-mode, which
makeClaudeAdapter passes unconditionally (T492) -- under the adapter's real flag set the
redefinition is dropped. --append-subagent-system-prompt-file does not exist on this binary. So
a subagent runs in the worker's cwd with Claude Code's stock instructions plus whatever the parent
typed into the delegation prompt, bounded by the host sandbox and the worktree, not by the prompt
contract. Nothing is wired for it; the lever is an operator decision (keep --safe-mode vs drop
it for fan-out workers and pass an --agents contract) recorded in the research note.
CLI workers are one-shot subprocesses: they run to completion without an inter-round hook, so a steer cannot interrupt them mid-flight. The steer text stays queued and is prepended to the next step's rendered prompt when the step-boundary drain fires (same effect as before T74). For CLI workers backed by a standby pool slot, the queued steer is also stored on the slot itself and prepended to the NEXT task dispatched through that slot (regardless of run boundary).
Programmatic steer (any worker)
printf '%s' '{"action":"steer","text":"prioritise the migration path; skip the UI polish"}' \
> .local/runs/<runId>/control.jsonThe engine reads it on the same 500ms poll, queues the text, and renames the file to
control.json.done (processed exactly once). Loop workers inject it at the next tool-round
boundary; CLI workers inject it at the next step boundary. Multiple steers queued before a
boundary are joined under a single [Operator steering]: heading.
Run-to-run delivery uses the message bus: a step can await a child run's completion via
RunStore.awaitInbox(addr, match, opts), which polls the parent run's bus inbox
(<CYBERINE_HOME>/bus/inbox/run-<parentRunId>.jsonl, routed by the Phase 0 publish seam) until a
matching run:completed envelope arrives or it times out (returns null when
CYBERINE_BUS_BACKEND=none). This is wired into the awaitRun workflow step type — see
"Define a workflow" below — so a step can delegate to a child run and block on it.
The view is a live fleet monitor: a bordered header shows a breadcrumb (fleet name, or the
selected run's workflow) plus live stats (run counts, total tokens, cost, ticking elapsed), and a
footer always lists the keys for the current screen. Running runs/workers animate a braille spinner
and tick their elapsed time; finished rows dim and freeze to ✔ (done) or ✕ (error); the
selected row carries an accent gutter bar ▌. State is color-coded (cyan = running, green = done,
red = error, yellow = cancelled, gray = queued). The animation runs off a single clock that ticks fast while any run is
live and slows when the fleet is idle, so smooth spinners never trigger extra disk reads.
Token usage is shown throughout: the fleet list shows each run's total tokens (and cost when a
provider reports it), run detail shows per-worker in/out counts, and the peek header shows the
selected worker's tokens plus a live throughput sparkline (chars/sec, derived from the output
file's growth so it moves token-by-token). Counts roll up WorkerRun.usage across every step.
NOTE (macOS / terminal): the TUI needs a REAL TTY - run it directly in your terminal, do not
pipe its output (Ink renders nothing to a pipe). Both the CLI adapters (opencode / claude /
codex) and the loop adapter (DeepSeek-direct / OpenRouter) emit live progress. The loop adapter
now streams the model's answer token-by-token (stream: true) straight into the worker peek view,
on top of its per-tool-loop-iteration events; usage is captured from the final stream chunk. For
DeepSeek "Think" models (deepseek-v4-pro) the chain-of-thought streams too, labelled [thinking]
then [answer] in the peek tail — but only the answer is kept as the worker's final output. Note
that reasoning consumes the LOOP_MAX_TOKENS budget, so set it high (thousands) for reasoning tasks.
Since T692 (2026-08-25) that [thinking] stream also shows up on DeepSeek runs that CALL TOOLS. The
loop adapter used to force thinking:{type:'disabled'} on every DeepSeek-direct request that carried
tools, on the (incorrect) belief that thinking mode ignores tools; both v4 models in fact return
tool_calls and reasoning_content in the same message. Practical consequences:
loop-deepseek-directandloop-deepseek-buildnow reason while working, and cost more output tokens for it (measured ~1.4–1.8x completion tokens on a small probe). SetCYBERINE_DEEPSEEK_TOOL_THINKING=offto get the old cheaper behaviour back with no code change.- Keep
LOOP_MAX_TOKENSat or above the default16000. Below 8000 the adapter disables thinking on its own, becausemax_tokenscaps CoT and the answer together and a hard task can burn the whole budget on CoT and return no tool call at all. LlmJudge/AdversarialJudgeare unaffected either way: they send no tools, so the old gate never applied to them.- Verify the policy any time with
npm run selftest:deepseek-thinking-tools(see below).
Known limitations and deferred work. There is no runtime recovery when a round comes back
finish_reason:'length' with zero tool calls because the chain-of-thought consumed the budget — the
8000-token floor prevents the pathological small-budget case, but an unusually long CoT above the
floor can still waste a round (the loop just retries). A "retry this round with thinking off" path is
the obvious follow-up and was deliberately left out of T692. The chat planner
(src/chat/providerTuning.ts) still forces thinking off for DeepSeek; its own reasons were not
re-litigated by T692 and changing interactive chat behaviour needs a real-TTY session to verify.
FIM's documented thinking-mode incompatibility was not re-probed (Cyberine does not use FIM), and the
/beta strict-schema endpoint was not probed with thinking on.
Run recipe — check the DeepSeek thinking/tool_choice policy without dispatching a fleet:
npm run selftest:deepseek-thinking-toolsIt pins the whole request-body truth table offline (no provider key needed, which is why CI can run
it) and, when DEEPSEEK_API_KEY is set, ALSO re-asserts the live provider contract: tools plus
thinking coexist, tool_choice:'required' with thinking on returns HTTP 400, and 'required' with
thinking:{type:'disabled'} succeeds. Without a key those three checks report skip, not FAIL.
Standby worker pool (opt-in)
By default, every cyberine chat /run (and cyberine run --bg) cold-spawns a fresh detached child, paying the Node+tsx startup cost each time. The standby pool pre-warms N named worker slots so a...
7. Implementer fleet: do a real task (multiple providers compete, judge picks the best diff)
Beyond Q&A/design, Cyberine can run a real file-changing task as an implementer fleet: several vendor coding agents each implement the same task in their OWN isolated git worktree, and a DiffJudge