Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 --short runs git status --short in the conversation’s working directory, as run_command would, 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.

    StepFor a model’s commandFor a typed ! command
    Policy deny and allow listsyesyes: a denied command is refused with the reason
    Seatbelt sandbox, writable rootsyesyes: the same profile
    Risk classifieryesno: it decides whether to ask the person, who has just typed the command
    The person’s approvalat moderate and aboveno: typing it is the approval
    Audit logyesyes, 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 /show for 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 with memory, exactly as a tool output becomes a reference after its turn. Tool facts are extracted from it as from run_command (the working directory, a test result), with source person. It is never presented as something the model did.

  • Chat and wisp-tui only. An MCP caller types nothing; respond runs 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 source person, but a value is held once across sources, so the workdir fact a chat records at its start and the same one from ! pwd do 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 from Amber (#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 is Sent (#121D27) where the box is Deep: #745A3C, half the box colour’s brightness as Sent is of Deep, 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 existing command.outcome event; documented in logging.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.
  • !cd does 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; /cd would 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 in wisp-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 (command and commandSent in 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 --json a new inbound line, {"type":"interrupt"}, is the front end’s Ctrl-C; wisp-tui sends it on the first Ctrl-C (or Ctrl-D) while a command it sent runs and quits on the second. The activity line marks such a command stoppable, then stopping. 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 it in the plain chat’s working line and wisp-tui’s busy box, ticking each second, then stopping … · Ctrl-C again quits.
  • Audited: command.outcome and command.typed carry stopped: true for a command the person stopped, and the thread record’s entry says so, so the model’s notice reads exit 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_command it started is stopped as a typed command is (its process group sent SIGTERM, then SIGKILL after CommandStop.grace, two seconds). Chat returns to the prompt with stopped by you and 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. ChatLoop makes one for each turn and arms ChatInterrupt with it; Agent binds it as a task local around the turn’s requests, as it binds the turn’s ReasoningObserver, 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’s run_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 --json the interrupt line stops the turn as Ctrl-C does. The activity line of a turn in a face that takes interrupt carries stoppable true, then doing stopping the turn with stopping true once one has arrived. wisp-tui sends interrupt on the first Ctrl-C (or Ctrl-D) while a typed command runs or while the turn’s activity says it is stoppable, shows stopping 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 its Ctrl-C stops it), and says stopping the turn · Ctrl-C again quits once 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, and killed when a second press killed what was running), beside the command’s own command.outcome with stopped true; the turn records no error, and /stats counts it as failed with the reason stopped by you.
  • Esc still stops nothing. Tests without a model: a scripted turn whose run_command ignores 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), the interrupt line through the router, a second press killing at once and a command started after the stop, the MLX engine’s generation, and in wisp-tui the first press sending interrupt during a turn and the second quitting.