ADR 0044: Host effects: what a tool asks the front end to do
Date: 2026-09-30. Status: accepted; built 2026-09-30 (see “Built” at the end). Amended 2026-09-30: fact approval, the second
request-and-answer effect, and its MCP mapping (below); amended again the same day, withdrawing that effect
(the last amendment below). Amends
ADR 0011 (approvers) and ADR 0030 (how a
notification is posted). Amended by ADR 0046: approval for an
MCP client without elicitation, through another face, and the notifications a respond turn posted. Amended by
ADR 0062, built 2026-10-12: the model’s question is the third host effect (Asker
on SessionHost), the one this record’s “Consequences” foresaw. Amended by
ADR 0048: how permanent facts are managed over MCP, left to the operator
by the second amendment below.
Context
Some things a tool needs done belong to whoever owns the person’s screen, not to wisp’s process.
- Approval already works this way.
ApprovalGateasks anApprover, and each entry point supplies its own:TerminalApproverasks on the terminal in plain chat,JSONApproversends anapprovalline overwisp chat --jsonandwisp-tuidraws the dialog, andElicitationApproverasks an MCP client through elicitation, which Claude Code renders. The model’s tool never knows which. notifydoes not.Notifierrunsosascript’sdisplay notificationfrom the wisp process, whatever the face. macOS attributes the banner to Script Editor (ADR 0030 recorded this), so it neither shows the terminal wisp runs in nor brings it forward when clicked.- Terminals can post notifications themselves. Ghostty, iTerm2, and WezTerm post one for the escape
sequence OSC 9 (
ESC ] 9 ; text BEL), and kitty for OSC 99, under their own name and icon, by each terminal’s documentation; the build probes each before routing to it. Terminal.app has no such sequence. A terminal is identified byTERM_PROGRAMand__CFBundleIdentifier(on this Mac on 2026-09-30:ghostty,com.mitchellh.ghostty). - Under
wisp-tui, the wisp process must not write to the terminal.wisp-tuirunswisp chat --jsonas a child and owns the screen: the alternate screen, raw mode, and every frame’s escape sequences. A child writing OSC 9 to/dev/ttywould interleave with the TUI’s frames from a second process, and a sequence split by another breaks both. Only the TUI can post it safely, between frames.
In discussion on 2026-09-30 this was named as a kind of tool that does not complete its work itself: it passes an instruction back to the controller, which has, in effect, a tool of its own being invoked.
Decision
-
A host effect is something a tool asks the session’s host (the face that owns the person’s screen) to do. There are two kinds:
Kind Instances What the tool gets back Fire and forget notifyThe route taken; delivery is not awaited Request and answer approval The host’s answer, within a bounded wait -
One
Hostper session holds the effects its face can carry. Tools and the gate call theHost, never a face directly.Approverstays the protocol for the approval effect; theHostcarries the session’s approver alongside its notifier. -
Each face declares what it can carry:
- plain chat and the one-shot CLI: what the process can do itself, including the terminal it runs in;
wisp chat --json: what the front end states in a new first line,hello, with its effects (forwisp-tui,approveandnotify). Nohellomeans today’s behaviour: approval over the protocol, and notifications posted locally;- MCP: what the client advertises (elicitation for approval), and the rest locally.
-
A request-and-answer effect obeys ADR 0011’s rule: a bounded wait (
approval.timeoutSeconds), denial by default, and an unanswered request audited as timed out. A host that cannot ask denies with a reason, as today. -
notifytakes the first route that works, in this order:- the front end, when it declared
notify: wisp sends anotifyline with the title, subtitle, and body, andwisp-tuiwrites OSC 9 (or OSC 99 for kitty) between frames; - the terminal, when wisp has a terminal (
/dev/ttyopens) andTERM_PROGRAMnames one that posts notifications: wisp writes the sequence to/dev/tty, never to stdout, which is the reply stream in chat and the protocol channel under MCP; - the terminal app by bundle identifier, when
__CFBundleIdentifieris set:display notificationsent to that app (tell application id …), so it posts under the app’s name. macOS asks once for Automation consent. This route is used only once a probe shows the banner attributed to the app on macOS 27; until then it is skipped; osascriptin wisp’s process, as today, attributed to Script Editor.
- the front end, when it declared
-
Unchanged from ADR 0030: text bounds, the per-minute limit across the process, the off switch, and no approval. The limit and bounds apply before any route, so a front end sees only what wisp would post itself.
-
Audited. The
notificationevent gainsroute(host,terminal,app,osascript). Approval events already name the entry point. -
Amended 2026-09-30 by the operator: fact approval is the second request-and-answer effect. Only the person admits a fact to the shared store of permanent facts (D2 and D3 of the layered-context proposal); a permanent fact a tool or the model proposes waits in its conversation until they do. The host carries a fact-approval effect (
FactApprover) beside the command approver, inSessionHost(theHostabove; Foundation already has a type of that name). What each face does with it:Face How the person approves a proposed permanent fact Plain chat, wisp chat --json,wisp-tuiNo dialog: /fact approve ID, as before./inspect factsalso lists the proposals of the process’s other conversations, and/fact approvetakes them by reference (git/c3)MCP, client with elicitation One fieldless Accept/Decline dialog per proposal, after the call’s result MCP, client without elicitation No dialog: the proposal waits in wisp://facts/proposedChoices made with it, each for the reason given:
- After the turn, never during it. The server claims the proposals at the end of each
tools/calland queues the dialogs; they are sent once the call’s response has been written (at most five seconds later), one at a time across threads. A tool call is never held open on the person. Waiting for the response was measured to matter: the SDK’s client handles its messages in order, and a dialog sent from inside the handler reached it before the result, which then waited for the person’s answer. - One question per proposal, not one per turn. A fieldless dialog can only accept or refuse exactly what it shows, and over MCP there is no other place to approve: an “accept all” dialog would make the person decline facts they want in order to refuse one they do not. Several in one turn are asked in a row, titled “(1 of 3)”. Distillation proposes at most twelve facts a condensation, and only permanent kinds are asked about.
- Fieldless, as the command dialog was after a form picker made Claude Code’s dialog unresponsive (ADR 0011).
- ADR 0011’s rule, with “not admitted” for “denied”: the same bounded wait
(
approval.timeoutSeconds,0waits forever); no answer leaves the proposal waiting, not admitted, and is audited astimed-out. - Accept admits the fact as approved by the person, ranking with the person, exactly as
/fact approvedoes. Decline (and Cancel) leaves it as the conversation’s proposal, not deleted, and is remembered by subject, name, and value for the life of the process, so wisp does not ask again about the same thing from any thread. A value the shared store already holds is not asked about. --yesdoes not approve facts. It stands for the person on commands; D2 keeps admission to the shared store with the person.- Audited on the proposing conversation’s log:
fact.approval.asked,fact.approval.decided(approved,declined,cancelled,timed-out,failed), andfact.approvedwithvia(chat,elicitation).
- After the turn, never during it. The server claims the proposals at the end of each
-
Amended 2026-09-30 (later, by the operator): the fact-approval effect is withdrawn. The dialog above was built and is removed before any release. A fact’s scope is a state the person sets by command, not a host effect, so
SessionHostcarries the command approver alone again (the notification effect above will be its second). What replaces it:- wisp lists new facts; it does not ask about them. After each turn, the facts the turn recorded or
changed are listed: a quiet note under the reply in chat (and a
noteline, plusfactson the turn’s end, inwisp chat --json, whichwisp-tuishows), andstructuredContent.factsin MCPrespond. A proposal waits, as a proposed fact of its conversation, until the person moves it. - One operation, named by target state: move fact ID to
permanent,thread, orsession. Scope and temporal class move together (D2 ties them:permanentis the shared store,threadthe conversation’s dynamic facts,sessionephemeral). Topermanentwrites the fact to the shared store as the person’s, ranking with the person as an approved fact did; out ofpermanentremoves it from the shared store into the target scope of the conversation doing the move. Deleting stays a separate command. Chat:/fact ID permanent|thread|session(/fact approveis gone). MCP:set_fact_scope. - Over MCP only
threadandsession.permanentis not offered, and a request naming it, or ap…fact, is refused with a message saying it is set from chat. How permanent facts are managed over MCP is for the operator to decide later; until then a client without a person at the keyboard cannot make a fact permanent, which is the safe side of D2. - Audited as one event,
fact.scope.changed(fact,from,to,by:personin chat,callerover MCP), replacingfact.approved,fact.approval.asked, andfact.approval.decided. Those three kinds stay in the schema, marked legacy, so a log written by a build that had them still reads. - Gone with the dialog: the ask queue, waiting for a call’s response to be written, the reflection
on the SDK’s handler context that found the response id, and decline memory. The reason is what the
dialog was for: it made wisp interrupt the person over MCP with a question that a list and a command
answer without holding a turn open. The choices made in the first amendment about ordering, batching,
timeouts, and
--yesno longer apply;--yesstill stands for the person on commands only.
- wisp lists new facts; it does not ask about them. After each turn, the facts the turn recorded or
changed are listed: a quiet note under the reply in chat (and a
Consequences
- In Ghostty, iTerm2, WezTerm, and kitty, and in
wisp-tuiinside them, banners come from the terminal and clicking one returns to it. Terminal.app waits on the third route’s probe. wisp-tuigains two things: it sendshello, and it handlesnotifylines. The protocol change is additive; an older front end sends nohelloand keeps today’s behaviour.- The next effects fit the same shape without new plumbing: showing output (D12 of the layered-context proposal already has the host render tool output), a choice among options, and a question to the person.
- Open, for MCP: MCP has no notification primitive, and a client without elicitation still cannot approve a command (ADR 0011, and the question of approval reaching every client, paused on 2026-09-17). The amendment of 2026-09-30 maps fact approval onto elicitation; commands and notifications remain for that discussion.
- Not yet probed: a dialog that arrives after a tool call’s result, in Claude Code. The protocol allows a server request at any time; whether Claude Code shows it while the model is still working is to be checked with the release build. (Moot for facts after the later amendment, which removed that dialog; it applies to any future effect that asks the person over MCP.)
- Tests without the model: route selection for each face and environment (terminal names, no tty, no
bundle identifier, a
hellowith and withoutnotify), the sequences written, the protocol lines both ways, and theroutefield in the audit.
Built
Built 2026-09-30. SessionHost carries the command approver and the notification routes
(NotificationRoutes) beside the session’s Notifier; Session.host(approver:face:) builds one for plain
chat and the one-shot commands (.terminal), wisp chat --json (.frontEnd), and wisp mcp (.mcp).
The gate asks host.approver; NotifyTool, wisp notify, and wisp watch post through host.notify.
hello is {"type":"hello","effects":[…],"client":…,"version":…}, recorded as the audit kind
host.hello; notify is {"type":"notify","title":…,"subtitle":…,"body":…,"sound":…}. The
notification event gains route and skipped (one route: reason per route passed over). Where the
build chose, and why:
wisp-tuideclaresnotifyonly when its terminal has a sequence (Ghostty, iTerm2, WezTerm, kitty, byTERM_PROGRAM, elseTERM). The alternatives were a reply saying it could not post, which makes a fire-and-forget effect a request and answer, or the front end runningosascriptitself, which puts policy in the front end. Declaring only what it can do keeps the effect one-way: in Terminal.app or tmux the front end leavesnotifyout, and wisp falls through to the routes of its own process.- Never the terminal route under MCP. The Decision allows it “when
/dev/ttyopens”. Probed on this Mac on 2026-09-30:wisp mcpspawned by Claude Code running in a terminal has that terminal (ttys006) as its controlling terminal, so/dev/ttyopens, while Claude Code draws on it; one spawned by the Claude desktop app has none. A sequence written from the server would interleave with the client’s frames, the hazard the Context names forwisp-tui. MCP takes the app andosascriptroutes only. Whether a client that owns no terminal should ever let the server write to one is for a later decision. - A
hellowithoutapprovedenies every approval without asking, with the reason, as “a host that cannot ask denies with a reason” says. Nohelloasks over the protocol as before. - The app route is behind
notifications.viaTerminalApp, andwisp notify --route appprobes it (it is tried even with the setting off);--routetakes any route alone. Probed by the operator on 2026-09-30 on macOS 27: the banner came from Terminal.app and from Ghostty, so the setting is on by default. The same probes found that Ghostty posts OSC 9 (and OSC 777) as its own banner, held back while its window has focus and posted once another app is in front; the terminal route keeps its place first, since a person looking at the terminal sees the output, and it needs no consent. - Sequence text is sanitised again for the terminal on top of
Notifier’s cleaning: every C0 and C1 control character and DEL becomes a space and;becomes,, in Swift (TerminalNotification) and inwisp-tui(notify.rs) alike. OSC 9 isESC ] 9 ; title — subtitle: body BEL; OSC 99 is a title chunk (i=<id>:d=0:p=title) and a body chunk (i=<id>:p=body), each ended byESC \. - Not probed here: that each terminal shows the banner. The sequences follow each terminal’s documentation; the tests check the bytes, not a banner.