ADR 0049: Commands the person types in chat
Date: 2026-10-04. Status: accepted. Amended by ADR 0054: the note for a
command the sandbox refuses comes from a check of the paths in its error, not a guess. Amended on 2026-10-09: a typed command
has no timeout, and the person stops it with Ctrl-C (below). Amended on 2026-10-11: Ctrl-C during a model’s turn stops
the turn and the command it started (below). Amended by ADR 0064
(amended 2026-10-14): a line typed after !! runs outside the sandbox, the step in the table below that a typed
command keeps; wisp-tui’s command mode gains a red outside mode one ! further.
Context
In chat the person can only ask the model to run a command; to run one themselves they leave wisp for
another terminal, and the model then knows nothing of what they did or saw. Claude Code and other agent
harnesses take a line that starts with ! as a command the person runs directly, whose output joins the
conversation. The operator asked for the same in wisp’s chat and wisp-tui (the roadmap’s 0.18.0), and
settled its terms on 2026-09-30.
It is the first command in wisp that does not come from the model. Every command until now has passed the same path (ADR 0011, ADR 0046): the policy’s deny and allow lists, the risk classifier, the person’s approval when the risk is moderate or above, the Seatbelt sandbox, and the audit log. That path exists because the model chooses the command; here the person does, which changes what each step is for.
Decision
-
A line in chat that starts with
!is a command the person runs.! git status --shortrunsgit status --shortin the conversation’s working directory, asrun_commandwould, with its bounds (the output tail, the timeout). It does not call the model and does not start a model turn. A line that is only!runs nothing. -
The checks that protect the Mac stay; the ones that stand in for the person go.
Step For a model’s command For a typed !commandPolicy deny and allow lists yes yes: a denied command is refused with the reason Seatbelt sandbox, writable roots yes yes: the same profile Risk classifier yes no: it decides whether to ask the person, who has just typed the command The person’s approval at moderateand aboveno: typing it is the approval Audit log yes yes, marked as the person’s The person keeps the sandbox’s protection against a mistyped or pasted command; a command the sandbox refuses (writing outside the writable roots) fails as it would for the model, and the message says so.
-
The output is shown in full, as tool output is (ADR 0045, D12): the person sees what the command printed, folded beyond
shownOutputLines, with/showfor the rest. -
The model is told, as a reference after the turn. The command and its output enter the thread record as the person’s own action: on the next request the model sees one entry such as “the person ran
git status --short(exit status 0, 4 lines)” with the output’s reference, recallable withmemory, exactly as a tool output becomes a reference after its turn. Tool facts are extracted from it as fromrun_command(the working directory, a test result), with sourceperson. It is never presented as something the model did. -
Chat and
wisp-tuionly. An MCP caller types nothing;respondruns commands through the model, as before. Nothing changes over MCP. -
Command mode is a state of the input box, in
wisp-tui. Typing!into an empty box switches it to command mode: the!is taken as the switch, not kept as text, and what follows is the command. Backspace or Delete in an empty box in command mode switches back to the normal prompt, so a stray!costs one key to undo. Sending in command mode runs the command; the box returns to the normal prompt afterwards. Plain chat has no box, so there a line that starts with!is the command, as above. Refined by the operator on 2026-10-04, before release: Backspace with the cursor at the start of the line also switches back, keeping the text typed so far as an ordinary message; and the opposite,!typed at the start of a line that has text switches it to command mode, the text becoming the command. Also on 2026-10-04: a fact from a typed command’s output keeps the sourceperson, but a value is held once across sources, so the workdir fact a chat records at its start and the same one from! pwddo not stand side by side; the stronger source keeps it (context-management.md, “Versions and precedence”). -
The input box shows command mode. In command mode the input box’s background changes from
Deep(#253B4E) to a muted pale amber, slightly more orange than yellow, with black text: a new palette colour,command, #E8B577, chosen by the operator on 2026-10-04 from three rendered candidates (#E8B577, #E2BC8C, #E5AA6E), defined in both palettes and checked by the gate’s palette check. It is distinct fromAmber(#F2B950), which marks approvals and warnings, so typing a command never looks like a warning. In the scrollback the command’s line is a stripe in a darker, faded variant,commandSent, as an ordinary prompt’s line isSent(#121D27) where the box isDeep: #745A3C, half the box colour’s brightness asSentis ofDeep, with light text, chosen by the operator from a render on 2026-10-04 over a fainter #856B4C with black text. Plain chat, which has no box, colours the prompt marker instead. -
The input box shows when wisp is busy. While a turn is processing, the input box is visibly inactive: dimmed, with a short status in place of the cursor (for example “working: read_file”), and typing is held until the turn ends rather than accepted invisibly. This is the same release’s second input-box change (the roadmap’s 0.18.0), recorded here because both change how the box reads.
-
Audited as
command.typed(the line, the directory, the policy decision, the outcome, and the output’s size), with the existingcommand.outcomeevent; documented inlogging.md.
Consequences
- The person can check something without leaving chat, and the model then knows what they saw.
- A typed command can do anything the sandbox allows without asking; that is the point, and the sandbox’s
writable roots still bound it. A command pasted from elsewhere runs as typed, which
!makes explicit. !cddoes not change the conversation’s working directory: each command runs in its own shell, as the model’s do. Changing the directory is left open;/cdwould be the natural form if it is wanted.- The thread record gains an entry kind for the person’s command; composition, the context view, and the sidecar carry it.
- Tests without the model: the parsing (
!,!, a bare!, a!inside a message), entering and leaving command mode inwisp-tui(!in an empty box, Backspace or Delete in an empty box, a!typed after other text staying text), each check applying or not as the table says, a denied command, a sandbox refusal, the output shown and folded, the entry the next request carries, the facts extracted, the audit, the TUI’s colours (commandandcommandSentin both palettes, the gate’s check) and the busy state, and that MCP is unchanged.
Refined on 2026-10-09 (0.21.1), after a code review. A fact from a typed command’s output no longer takes the
source person. Ranked with the person, an observation pinned a stale value: a passing ! swift test outranked
the model’s later failing run of the same command, and nothing but the person’s own word could replace it. What a
command printed is an observation, whoever ran it, so its facts are recorded with the source tool and the detail
the person's command (shown as from the person's command, turn N, entry M), and the newer observation wins.
person stays for what the person states (/fact, /task). The workdir a chat records at its start keeps its
source, and a ! pwd that gives the same value still adds nothing. Also: a typed command is no turn of its own in
condensing; it goes with the turn after it, so the floor of one turn keeps the last turn the model took part in
(context-management.md, “Condensing”), and its notice without memory says not to
run it again, as any command’s reference does (ADR 0057).
Amendment, 2026-10-09: no timeout for a typed command. The decision above ran a typed command “with its
bounds (the output tail, the timeout)”. The operator ran ! ollama pull hf.co/ornith-ai/Ornith-1.5-9B-GGUF:Q4_K_M
in chat, and wisp killed it at run_command’s 60-second timeout (exit -15) with 3.2 of 5.8 GB fetched; they asked
for the timeout to go. The timeout stands in for the person, as the classifier and the approval do: it stops a
model’s command that would run unwatched, and the person who typed a command is watching it. So a typed command
keeps the output bound (the tail of each stream, the fold, /show) and loses the timeout; the model’s run_command
keeps its timeout unchanged.
- The person stops it. Since nothing stops it automatically, Ctrl-C does, in both faces: while a typed command
runs, the first Ctrl-C sends its process group SIGTERM and SIGKILL two seconds later (
CommandStop, the runner’s group kill), and chat goes on; its outcome line reads↳ exit -15 (stopped by you). Ctrl-C does not quit chat then. A second Ctrl-C while it is still stopping kills it at once and quits, as Ctrl-C quits during a model’s turn and at the prompt, which this leaves as they were. Esc stops nothing, as it stops no turn. - Over
wisp chat --jsona new inbound line,{"type":"interrupt"}, is the front end’s Ctrl-C;wisp-tuisends it on the first Ctrl-C (or Ctrl-D) while a command it sent runs and quits on the second. Theactivityline marks such a commandstoppable, thenstopping. When wisp’s input closes while one runs, wisp kills it, so a command outlives neither face. - It shows that it is running and how to stop it:
… 42 s · running ollama pull … · Ctrl-C stops itin the plain chat’s working line andwisp-tui’s busy box, ticking each second, thenstopping … · Ctrl-C again quits. - Audited:
command.outcomeandcommand.typedcarrystopped: truefor a command the person stopped, and the thread record’s entry says so, so the model’s notice readsexit status -15, stopped by the person. - Plain chat now takes SIGINT itself for the whole session, raising it again with the default disposition when
no typed command runs, so that Ctrl-C still quits there; commands are spawned with SIGINT reset to its default,
since an ignored signal is inherited across
exec. - No setting. A cap for typed commands is not added: the person who would set it is the one watching the command, and Ctrl-C is the cap. Streaming a typed command’s output live is left for later; the runner captures output into buffers it reads at the end, so it needs a callback per chunk and a face that redraws the fold.
Amendment, 2026-10-11: Ctrl-C during a model’s turn. The amendment above left Ctrl-C during a model’s turn as it
was: in plain chat it quit wisp at once, and a run_command the model had started in that turn could outlive it,
since a command runs in a process group of its own, which the terminal’s SIGINT does not reach (found on 2026-10-09
while giving typed commands Ctrl-C). In wisp-tui Ctrl-C quit during a turn too. The roadmap’s 0.22.0 gave the turn
what typed commands have.
- The first Ctrl-C stops the turn, in both faces: the model’s request under way is cancelled, and a
run_commandit started is stopped as a typed command is (its process group sent SIGTERM, then SIGKILL afterCommandStop.grace, two seconds). Chat returns to the prompt withstopped by youand goes on. A second Ctrl-C while it is stopping kills what the turn still has running at once and quits, as a second press does for a typed command. With nothing under way, Ctrl-C quits as before. - One stop per turn,
TurnStop.ChatLoopmakes one for each turn and armsChatInterruptwith it;Agentbinds it as a task local around the turn’s requests, as it binds the turn’sReasoningObserver, and what the turn has under way attaches to it while it runs: a local runtime’s request (its connection is cancelled through the task’s delegate, so a request still waiting for its first byte is cancelled too; MLX’s generation is cancelled, which its runtime checks between tokens), and a model’srun_command, which follows the turn’s stop when it has no stop of its own. Each executor checks the stop before it sends, and a streamed reply is checked at each snapshot, so a runtime that cannot be cancelled mid-request (Apple’s models) stops at its next words. A command the turn would start after the stop ends as it starts. - Over
wisp chat --jsontheinterruptline stops the turn as Ctrl-C does. Theactivityline of a turn in a face that takesinterruptcarriesstoppabletrue, thendoingstopping the turnwithstoppingtrue once one has arrived.wisp-tuisendsinterrupton the first Ctrl-C (or Ctrl-D) while a typed command runs or while the turn’s activity says it is stoppable, showsstopping the turn · Ctrl-C again quits, and quits on the second; when its input closes, wisp kills what the turn has running, as it kills a typed command. The plain chat’s working line gives no hint while a turn runs (a typed command, which has no timeout, keeps itsCtrl-C stops it), and saysstopping the turn · Ctrl-C again quitsonce stopping. - The record stays whole. The framework keeps nothing of a request that threw, so the stopped turn is recorded in the thread from its audit events: the person’s prompt, and each tool call that returned with its output (a stopped command’s says it was stopped), so the next request knows what was asked and what ran. A call whose output never came is left out, with any answers it had, so no request carries a call without its answer. There is no reply entry, and none is made up. The next request composes as after any turn.
- Audited as
turn.stopped(seconds, andkilledwhen a second press killed what was running), beside the command’s owncommand.outcomewithstoppedtrue; the turn records no error, and/statscounts it as failed with the reasonstopped by you. - Esc still stops nothing. Tests without a model: a scripted turn whose
run_commandignores SIGTERM, stopped by a press (its group gone by pid within bounded waits, chat going on, the activity, the audit, the record, and the next request), a request still streaming (a scripted model that waits, and FakeOllama holding a connection open), theinterruptline through the router, a second press killing at once and a command started after the stop, the MLX engine’s generation, and inwisp-tuithe first press sendinginterruptduring a turn and the second quitting.