Expand description
Backend half of the /btw slash command — a faithful recreation of
Claude Code CLI’s own native /btw: a fast, one-shot, tool-less answer
that is aware of the current conversation (via a text prefix, not
--resume), WITHOUT touching the asking pane’s persisted transcript and
WITHOUT blocking/queuing behind that pane’s own live turn.
§Why a throwaway block
SubprocessController::spawn_turn
(blockcontroller/subprocess/host_spawn.rs) is strictly one-turn-at-a-
time PER BLOCK — a second spawn_turn on a block that’s already running
queues behind it (inner.pending_messages), plus there’s a cross-
process session-ownership lease on top. Calling it again on the SAME
block id that owns the pane’s live turn would therefore queue behind
that turn and defeat the entire point of /btw.
So this spawns the one-shot turn against a FRESH, throwaway block id —
a different block id means a different SubprocessController instance
(blockcontroller’s registry is keyed per block), so there is no
queue/lease contention with the asking pane’s real live turn, while
still going through the exact same run_agent_turn/AgentTurnDeps
pipeline every real agent turn uses — identity injection, the OAuth
spawn gate that “fails closed”, muxbus/bashwrap env, PATH, git identity
— instead of hand-rolling a second, parallel spawn path that bypasses
all of it. See input.rs’s run_agent_turn doc comment for why that
machinery must not be reimplemented.
The throwaway block deliberately carries no agentId / agentName /
agentMode / agent:* meta:
- No
agentId⇒shell::resolve_global_output_zone(file_ops.rs) resolves toNonefor this block, sospawn_turn’s own stdout mirroring never writes into any real agent’s persistedagent:<defId>:currenttranscript zone. - It is never linked into any tab/layout tree and is deleted (with
its controller and MPS-persisted history) the moment the turn ends
— see
cleanup_throwaway_block. - Known scope limitation: with no
db_agent_instancesrow bound to this block,inject_identity_env_async(identity/resolver/inject.rs) takes its documented “block has no agent instance row — nothing to inject” early return, the same behavior an ad hoc quick-launch pane already gets. A/btwasked from a pane whose agent is bound to a specific non-ambient identity does not inherit that binding; it runs on ambient credentials. Reproducing the source pane’s exact bound-identity resolution would mean minting (and tearing down) a realdb_agent_instancesrow for a block that is never a real agent — left as a follow-up. - Similarly, no
agentModemeta ⇒ always the plain host subprocess branch, never the containerdocker execbranch, regardless of what the source pane is. A/btwfrom a container agent’s pane therefore answers from the HOST’s view of the provider CLI.
§Providers
/btw is an AgentMux feature, not a pass-through to a CLI’s own, so it
runs on Claude, Codex and Gemini panes alike (BtwProvider, from the
source pane’s agentProvider). Each gets its own tool-less one-shot
argv — build_side_question_argv, build_codex_side_question_argv,
build_gemini_side_question_argv — and its own answer translator. The
turn being tool-less on every provider is what keeps a side question
outside the agent identity system: it can’t call the agentmux tools,
so it never acts as the pane’s agent (#3579). Other providers are
refused with an error chunk.
§Output contract
The turn’s own stdout is written to the THROWAWAY block’s own
FileStore-backed output file by spawn_turn exactly like any other
turn (needed so this module can read it back) — but that file’s block
scope (block:<throwaway_id>) is never surfaced to any pane; nothing
is ever subscribed to it, and the block + its persisted history are
deleted as soon as the turn ends.
Instead, this module polls that file for newly-appended JSON lines
(there is no in-process “call me back on new bytes” primitive in this
codebase — reactive::progress_watcher polls the same way for the same
reason), translates each line through the provider’s translator
(agents/translator/ — for Claude, the SAME ClaudeTranslator the
drone Agent block runner uses), and republishes one mps::EVENT_BTW_ANSWER_CHUNK per translated
AgentEvent — scoped to the SOURCE pane’s block id (block_id in the
request) plus the minted request_id, never the throwaway block’s own
scope. A final chunk carries "done": true.
Enums§
- BtwProvider 🔒
- The CLI a
/btwruns on — the source pane’s. See the module doc’s “Providers” section.
Constants§
- COPIED_
META_ 🔒KEYS - Meta keys deliberately copied from the source block onto the throwaway block — its whole one-shot CLI configuration, nothing agent-identity- shaped. See this module’s doc comment for what’s excluded and why.
- MAX_
READ_ 🔒BYTES - Cap on bytes read per poll — defensive only; a tool-less, single-turn response is never remotely this large.
- MAX_
WAIT 🔒 - Max time to wait for a translated terminal event (
AgentEvent::Done/Error) before giving up and synthesizing one. A--max-turns 1tool-less turn should finish in a few seconds; this is a generous backstop against a wedged/never-exiting subprocess, not the expected path. - POLL_
INTERVAL 🔒 - How often to re-check the throwaway block’s output file for new bytes.
Tight relative to
reactive::progress_watcher’s 3s sweep — that loop amortizes a fixed per-tick cost across every live agent block; this one drives a single, short-lived, tool-less turn where a snappy overlay matters more than sweep cost.
Functions§
- btw_
scope 🔒 - The MPS scope a
/btwanswer streams on — the SOURCE pane’s block id, never the throwaway block that actually ran the turn. - build_
prompt 🔒 - The prompt sent to the CLI:
context_snapshotprepended toquestion. No--resumeon this path — live session context is deliberately carried as a text prefix instead (concurrent--resumeon a live session is unsafe per this module’s own doc comment). - build_
throwaway_ 🔒meta - Build the throwaway block’s meta from the source block’s — its one-shot
CLI config with
provider’s tool-less/btwargv, minus every agent-identity-shaped key.gemini_policyis the deny-all policy path (read only for Gemini). Pure and unit-testable; see the module doc comment for the exclusion rationale. - cleanup_
throwaway_ 🔒block - Kill the throwaway controller, delete its block row, and purge any MPS-
persisted history under its scope — mirrors
COMMAND_DELETE_SUB_BLOCK’s kill-then-delete ordering (server/websocket.rs). Best-effort: this runs after the turn is already over, so a failure here is a leftover row/controller to log, not a failed/btwanswer. - drain_
and_ 🔒publish - Poll the throwaway block’s own
outputFileStore subject for newly appended stream-json lines, translate each intoAgentEvents, and publish oneEVENT_BTW_ANSWER_CHUNKper event onscope. Returns once a terminal event (Done/Error) has been published, or after synthesizing one on timeout / unexpected exit. See the module doc comment for why this polls FileStore directly instead of hooking intospawn_turn’s own output path. - ensure_
gemini_ 🔒policy - Write
GEMINI_DENY_ALL_TOOLS_POLICYwhere--policycan read it, and return its path. Under the AgentMux data dir rather than a shared temp dir, where another local user could swap in a permissive policy. Written to a temp file and renamed, so a concurrent/btwnever reads a half-written policy. - ensure_
gemini_ 🔒policy_ in - publish_
chunk 🔒 - Publish one
EVENT_BTW_ANSWER_CHUNK. Seemps::EVENT_BTW_ANSWER_CHUNK’s own doc comment for the payload shape and scope-string contract. - register
- run_
side_ 🔒question - Drive one
/btwrequest end to end: spawn the throwaway turn, drain + translate + republish its output, then clean up. - spawn_
throwaway_ 🔒turn - Create the throwaway block and drive it through
run_agent_turn. Returns the throwaway block id on success — including whenrun_agent_turnitself fails after the block was created (the block + controller still exist and get cleaned up normally;drain_and_publish’s own not-running-with-nothing-published fallback surfaces that failure, and in the common failure case — the identity/OAuth spawn gate — the block’s own output file already carries a translatable errorresultframe, so the real message reaches the frontend either way). Only returnsErrwhen nothing was ever created (source block missing, insert failed) — there is then genuinely nothing to poll or clean up.