zidane 7.0.15 → 7.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/IPC.md CHANGED
@@ -1,9 +1,14 @@
1
1
  # zidane/ipc — shared chat host, daemon, and multi-window sync
2
2
 
3
- `zidane/ipc` is the layer that lets several zidane windows — terminal
4
- sessions, the GUI, `zidane attach` clients — share **one set of live agents**
5
- and stay converged on the same state: the same transcript stream, the same
6
- pending approvals, the same interaction prompts.
3
+ `zidane/ipc` lets several zidane windows — terminal sessions, the GUI,
4
+ `zidane attach` clients — share **one set of live agents** and stay
5
+ converged: same transcript, same pending approvals, same interaction
6
+ prompts. The engine (`createChatHost`) is transport-agnostic; the daemon
7
+ is one embedding of it, reached over a unix socket with JSON-RPC.
8
+
9
+ Entry points: `src/ipc/daemon/server.ts` (`startIpcDaemon`), `src/ipc/client.ts`
10
+ (`connectIpcClient` / `connectOrSpawnDaemon`), `src/tui/runtime/daemon.ts`
11
+ (`connectDaemonSession`, the TUI's daemon-mode controller).
7
12
 
8
13
  ```
9
14
  ┌────────────┐ ┌────────────┐ ┌─────────────┐
@@ -22,294 +27,280 @@ pending approvals, the same interaction prompts.
22
27
  └────────────┘
23
28
  ```
24
29
 
30
+ Contents: [Pieces](#the-pieces) · [Host environment](#host-environment) ·
31
+ [RPC methods](#rpc-methods) · [Signals](#signals-and-convergence) ·
32
+ [Concurrent sends](#concurrent-sends) · [Extension bus](#extension-bus-ctxbus) ·
33
+ [Daemon lifecycle](#daemon-lifecycle) · [Turning it off](#turning-it-off-containment-guarantees) ·
34
+ [Execution semantics](#execution-semantics-read-this) · [Testing](#testing) ·
35
+ [Surface matrix](#surface-cabling-matrix-daemon-runtime) · [Removability](#removability) ·
36
+ [Status](#status--follow-ups)
37
+
25
38
  ## The pieces
26
39
 
27
40
  | Module | What it is |
28
41
  | --- | --- |
29
- | `protocol.ts` | Transport contract: the typed `IpcMethods` RPC map, `IPC_PROTOCOL_VERSION`, handshake shapes. Re-exports the host-domain shapes (`HostSignal` & co.) from `src/chat/host/signals.ts`. |
30
- | `src/chat/host/` | `createChatHost(env)` — the transport-agnostic engine. Owns live `Agent`s, forwards agent-loop hooks as signals, runs the approval + interaction hubs. Lives under `src/chat` (it's the chat engine, not an IPC feature); `zidane/ipc` re-exports it for back-compat, new code imports it from `zidane/chat`. |
31
- | `daemon/` | Unix-socket JSON-RPC server (`startIpcDaemon`), disk-backed environment, lifecycle helpers (socket path, liveness probe, spawn-on-demand). |
32
- | `client.ts` | `connectIpcClient` / `connectOrSpawnDaemon` — typed `request()`, `onSignal()`, protocol-version handshake with automatic stale-daemon drain. |
33
- | `attach.ts` | `zidane attach` — a thin readline client proving the multi-window contract end-to-end. |
42
+ | `src/ipc/protocol.ts` | Transport contract: typed `IpcMethods` map, `IPC_PROTOCOL_VERSION`, handshake shapes. Re-exports `HostSignal` & co. from `src/chat/host/signals.ts`. |
43
+ | `src/chat/host/` | `createChatHost(env)` — the transport-agnostic engine. Owns live `Agent`s, forwards loop hooks as signals, runs the approval + interaction hubs. |
44
+ | `src/ipc/daemon/` | Unix-socket JSON-RPC server (`startIpcDaemon`), disk-backed environment, lifecycle helpers (socket path, liveness probe, spawn-on-demand). |
45
+ | `src/ipc/client.ts` | `connectIpcClient` / `connectOrSpawnDaemon` — typed `request()`, `onSignal()`, version handshake with stale-daemon drain. |
46
+ | `src/ipc/attach.ts` | `zidane attach` — a thin readline client proving the multi-window contract end-to-end. |
34
47
 
35
48
  ## Host environment
36
49
 
37
50
  `createChatHost` never touches disk or Electron directly — the embedding
38
51
  process supplies a `ChatHostEnvironment`:
39
52
 
40
- - **GUI main** (`gui/src/main/chat/host.ts`): broadcast → `webContents.send`
41
- demuxed onto the legacy renderer channels; settings in `state.json`'s
42
- `settings` slot (shared with the TUI and the daemon; the legacy sqlite
43
- row is migrated by `initSettings`), pending approvals in sqlite; the
44
- extension-aware runtime-config cache.
53
+ - **GUI main** (`gui/src/main/chat/host.ts`): broadcast → `webContents.send`;
54
+ settings in `state.json`'s `settings` slot (shared with the TUI/daemon);
55
+ pending approvals in sqlite.
45
56
  - **Daemon** (`src/ipc/daemon/environment.ts`): broadcast → socket peers;
46
- settings in `state.json`'s `settings` slot (shared with the TUI); pending
47
- approvals in `pending-approvals.json`; per-cwd extension discovery with
48
- the same allowlist + trust contract as every other surface.
57
+ same `state.json` settings slot; pending approvals in
58
+ `pending-approvals.json`; per-cwd extension discovery with the same
59
+ allowlist + trust contract as every other surface.
60
+
61
+ Both run the same engine — the gate flow, edit-approval pipeline,
62
+ compaction, and hook forwarding cannot drift between surfaces.
49
63
 
50
- Both run the **same engine** — the gate flow, edit-approval pipeline,
51
- compaction, and hook forwarding cannot drift between surfaces anymore.
64
+ ## RPC methods
65
+
66
+ Client → host, daemon-dispatched. All are mirrored by the GUI's embedded
67
+ path through the same `ChatHost` method. Source of truth: `IpcMethods` in
68
+ `src/ipc/protocol.ts`.
69
+
70
+ | Method | Purpose |
71
+ | --- | --- |
72
+ | `chat.send` | Send a prompt; starts a run (errors if already running elsewhere). |
73
+ | `chat.interrupt` | Stop the session's active run. |
74
+ | `chat.steer` | Inject a message into the live run. |
75
+ | `chat.followUp` | Queue a message on the agent to run after the current turn (no steer). |
76
+ | `chat.pushQueued` | Race-free send: steers a live run, sends otherwise. |
77
+ | `chat.isRunning` | Whether the session has a run in flight. |
78
+ | `chat.rebuildAgent` | Retire the cached agent so the next run rebuilds (fresh MCP/creds/extensions). |
79
+ | `chat.listWork` | List cancellable tool calls / background tasks for the session. |
80
+ | `chat.cancelWork` | Cancel one work item, or `'all'`. |
81
+ | `sessions.list` | List sessions, filtered by project root / archive state. |
82
+ | `sessions.create` | Create a session. |
83
+ | `sessions.delete` | Delete a session. |
84
+ | `sessions.setTitle` | Rename a session. |
85
+ | `sessions.setArchived` | Archive / unarchive a session. |
86
+ | `sessions.setAgentId` | Change a session's agent profile. |
87
+ | `sessions.setEffort` | Change a session's thinking-effort level. |
88
+ | `sessions.setModel` | Set provider/model; `fallbacks` patches the chain (omit = untouched, `[]` = clear). |
89
+ | `sessions.setModelOptions` | Set per-model boolean options. |
90
+ | `sessions.loadEvents` | Canonical transcript replay — the re-convergence source of truth. |
91
+ | `sessions.getTurn` | Fetch one turn. |
92
+ | `sessions.contextBreakdown` | Context-usage breakdown for the context panel. |
93
+ | `sessions.artifacts` | One session's artifacts — see [ARTIFACTS.md](./ARTIFACTS.md). |
94
+ | `sessions.touch` | Announce an out-of-band local `/`/`!` append; fans out `sessions:changed`. |
95
+ | `sessions.compact` | Trigger manual compaction. |
96
+ | `sessions.systemMd` | Attach-time backfill of the resolved `SYSTEM.md` override. |
97
+ | `sessions.contextSegments` | Every prompt segment with provenance (Instructions tab). |
98
+ | `sessions.createSystemOverride` | Seed a user-scope `SYSTEM.md`; throws when no override could apply. |
99
+ | `artifacts.list` | Cross-session artifact query — see [ARTIFACTS.md](./ARTIFACTS.md). |
100
+ | `artifacts.delete` | Remove an artifact — see [ARTIFACTS.md](./ARTIFACTS.md). |
101
+ | `artifacts.upsert` | Refresh an artifact (lazy `resolve()` write-back) — see [ARTIFACTS.md](./ARTIFACTS.md). |
102
+ | `history.searchSession` | Transcript search over one session, against authoritative mid-run-fresh turns. |
103
+ | `history.searchAll` | Cross-session content search; one shared index built in the daemon. |
104
+ | `todos.get` | Current todos snapshot for a session. |
105
+ | `todos.set` | Apply a user todo edit to the live agent's `Session` (what `todowrite` reads). |
106
+ | `orchestration.main` | Resolve/create the pinned "main" session id. Gated on the `orchestration` setting. |
107
+ | `orchestration.createWorker` | Create a worker session under a main. |
108
+ | `orchestration.list` | List a main's workers. |
109
+ | `orchestration.read` | Read one worker's summary. |
110
+ | `orchestration.send` | Send a prompt to a worker; `wake` (default true) wakes the main on completion. |
111
+ | `orchestration.steer` | Steer a worker's live run. |
112
+ | `orchestration.cancel` | Cancel a worker's run. |
113
+ | `orchestration.transcript` | Fetch a worker's transcript text. |
114
+ | `commands.list` | Extension-contributed `/` commands discovered for a cwd. |
115
+ | `commands.completeArgs` | Argument suggestions for `/name <prefix>`. |
116
+ | `interactions.list` | Pending `present_plan` / `ask_user` requests. |
117
+ | `interactions.respond` | Answer an interaction. |
118
+ | `interactions.cancel` | Cancel an interaction. |
119
+ | `interactions.rehydrate` | Re-queue orphaned interactions from a reopened session. |
120
+ | `approvals.resolve` | Resolve a safe-mode approval request. |
121
+ | `approvals.denyAll` | Deny every pending approval (optionally scoped to a session). |
122
+ | `approvals.listPending` | Pending approvals — backfill for a late attacher. |
123
+ | `queue.list` | Current type-ahead queue for a session. |
124
+ | `queue.add` | Queue a message while a run is in flight; sends immediately otherwise. |
125
+ | `queue.remove` | Drop a queued entry. |
126
+ | `queue.update` | Edit a queued entry's text in place; order is kept. |
127
+ | `queue.pushNow` | Steer a queued entry into the live run now (sends if idle). |
128
+ | `drafts.get` | Current shared draft — backfill on window attach. |
129
+ | `drafts.set` | Replace the shared draft text (notification, fired per keystroke). |
130
+ | `drafts.setAttachments` | Replace the shared draft's composing attachments (notification). |
131
+ | `search.set` | Replace the shared transcript-search state (notification, per keystroke). |
132
+ | `extension.emit` | Forward a client-side `ctx.bus` emit (`'ipc'` permission); daemon validates + re-broadcasts. |
133
+ | `suggest.resolve` | Resolve inline ghost-text autocomplete daemon-side; latest `seq` per session wins. |
134
+ | `settings.get` | Read host settings. |
135
+ | `settings.set` | Patch host settings; fans out `settings:changed`. |
136
+ | `presence.list` | Current presence snapshot — backfill for a freshly opened session list. |
137
+ | `presence.watch` | Tag this connection as attached to a session; detach is implicit on close. |
138
+ | `daemon.info` | Handshake / diagnostics info. |
139
+ | `daemon.shutdown` | Graceful drain: deny pending approvals, abort live runs, close the socket. |
140
+
141
+ All methods above `IPC_PROTOCOL_VERSION` 9 are additive: an older daemon
142
+ answers "method not found" and clients degrade (local compute, or a
143
+ store-level fallback) instead of failing. `optional()` in
144
+ `src/tui/runtime/daemon.ts` is the TUI's wrapper for that pattern.
52
145
 
53
146
  ## Signals and convergence
54
147
 
55
- Every state change broadcasts a `HostSignal` to every connected client:
56
-
57
- - `run:start` / `event` / `run:end` — the live stream. A window attaching
58
- mid-run starts receiving events immediately and rebuilds prior history via
59
- `sessions.loadEvents` (canonical `eventsFromTurns` replay).
60
- - **The user message is part of the stream**: the host broadcasts a
61
- `user-prompt` event for every send / steer / follow-up, BEFORE
62
- `run:start`, and stamps the sender's own copy `echo: true` —
63
- ORIGIN-AGNOSTICALLY (one broadcast, every peer sees the tag; the host
64
- cannot know which window originated the send). Clients that render
65
- purely from the broadcast (TUI, attach) ignore the flag. A client that
66
- appends its own sends optimistically (the Electron GUI, for
67
- refs/attachments enrichment) must dedupe CLIENT-SIDE: consume exactly
68
- one pending optimistic append per matching echo and render every other
69
- user prompt — echo-tagged prompts with no pending match are genuine
70
- remote sends (another window, a queue drain). Never blanket-drop
71
- echo-tagged prompts in an adapter: that swallows other windows'
72
- messages (see `StreamStore._pendingEcho`).
73
- - `queue:changed` — the shared type-ahead queue. Host-owned: `queue.add`
74
- queues while a run is in flight (from ANY window) and sends otherwise;
75
- the host drains FIFO at run end, so queued messages run even after the
76
- window that queued them closed. Every window renders the same queue box
77
- and can drop (`queue.remove`) or steer (`queue.pushNow`) any entry.
78
- - **Re-convergence guarantee**: at every run end (and on reconnect) the
79
- TUI replaces its transcript with the canonical `sessions.loadEvents`
80
- replay — any signal a window missed (disconnect, attach gap, daemon
81
- respawn) is reconciled at the next run boundary, so windows cannot
82
- drift apart permanently. A dropped daemon connection auto-reconnects
83
- (re-activation with full replay) after a short grace.
84
- - **Slash commands execute HOST-SIDE** (`chat.send` / `chat.pushQueued`
85
- route them; full parity incl. agent-spawning commands). The TUI's own
86
- `/` commands and `!` shell still execute window-locally by design
87
- (UI-coupled); their persisted transcript effects converge immediately
88
- via `sessions.touch`.
89
- - `search:changed` — the shared transcript-search state (mode + query):
90
- entering/exiting search or editing the query in one window mirrors in
91
- every window on the session, same origin-excluded last-writer-wins
92
- contract as drafts, backfilled on attach. Draft publishing is
93
- suppressed while searching (the buffer holds the QUERY; the parked
94
- draft stays the shared text) and shell mode rides the draft as an
95
- out-of-band `shell` flag — never encoded into the text, so a literal
96
- leading `!` in a normal draft can't flip other windows into
97
- shell-execution semantics.
98
- - `draft:changed` — the shared per-session prompt draft. The daemon
99
- excludes the ORIGINATING peer from the fan-out, so a window's own
100
- keystrokes never bounce back and fight its cursor (echo-safety by
101
- construction, no client-side dedupe). Last-writer-wins whole-text
102
- replacement; drafts are ephemeral daemon memory (`drafts.get/set`),
103
- backfilled on attach, and cleared for every other window when any
104
- window submits. The TUI publishes EVERY keystroke as a fire-and-forget
105
- JSON-RPC notification (no id, no response, FIFO on the socket — the
106
- last keystroke always wins), so other windows mirror the draft at
107
- keystroke latency; remote drafts apply through the same imperative
108
- `setText` path the external-editor reload uses.
109
- - `settings:changed` — settings written through the host fan out so every
110
- window's settings UI converges live. The TUI publishes its local
111
- settings changes through the daemon when in daemon mode; behavioral
112
- reads (safe-mode gates, etc.) were already convergent because the
113
- daemon re-reads `state.json` per call.
114
- - `approval:request` / `approval:resolved` — the safe-mode queue. Any window
115
- may answer; `approval:resolved` clears the surface everywhere else. Pending
116
- rows survive daemon restarts (persisted before broadcast) and are listable
117
- via `approvals.listPending` for late attachers.
118
- - `interaction:request` / `interaction:resolved` — `present_plan` /
119
- `ask_user`, same fan-out semantics.
120
- - `sessions:changed` — create/update/delete, keeps session lists in sync.
121
- - `artifact:recorded` / `artifact:changed` — the session recorded an artifact
122
- (PR / branch / commit / file / extension kind; see EXTENSIONS.md → Session
123
- artifacts) or one was deleted / refreshed through the host. Both are
124
- wake-ups: windows re-query `sessions.artifacts` (one session) or
125
- `artifacts.list` (project-wide / reverse lookup by `kind` + `uri`); writes go
126
- through `artifacts.delete` / `artifacts.upsert` (the lazy `resolve()`
127
- write-back). All additive — a pre-artifact daemon rejects the methods and
128
- clients treat that as empty.
129
- - `work:snapshot`, `todos:snapshot`, `compacting:start` / `compacting:progress`
130
- / `compacting:end`, `model:fallback`, `stream:retry` / `stream:retry:cleared`,
131
- `cache:break` — auxiliary indicators. `compacting:progress` carries the live
132
- summary-token estimate; `stream:retry:cleared` is a distinct kind (not a
133
- nullable `attempt`) so a subagent stream dying mid-run clears only its own
134
- chip.
135
- - `prompt-turns` — prompt turns that just persisted, reduced to
136
- `PromptTurnRef` (`id` + texts + attachment-only) so a window can bind its
137
- live `user-prompt` echo to a turn id during the run. Deliberately not the
138
- full `SessionTurn`: those carry base64 attachment payloads.
139
- - `event { kind: 'session-event' }` — a worker's outcome delivered to its main
140
- session (rides the existing `event` signal; no new kind). Clickable in both
141
- clients: activation opens the worker.
142
- - `system-md` — which `SYSTEM.md` override (if any) the host folded into the
143
- session's prompt, emitted at agent-build time, plus the precedence losers.
144
- Windows cannot derive this locally: the daemon owns the cwd and the
145
- `userInstructionsScope` setting.
146
-
147
- RPCs added alongside (all daemon-dispatched, all mirrored by the GUI's
148
- embedded path through the same `ChatHost` method):
149
-
150
- - `todos.set` — apply a user todo edit to the `Session` the MODEL reads
151
- (`ChatHost.setTodos`, validated + capped host-side). Returns the resulting
152
- snapshot, or `null` when no agent is live so the caller falls back to a
153
- store-level write. This is what makes the todos modal's edits survive the
154
- model's next `todowrite`.
155
- - `sessions.systemMd` — attach-time backfill of the resolved `SYSTEM.md`
156
- (windows cannot probe the daemon's filesystem).
157
- - `sessions.contextSegments` — the Instructions tab: every prompt segment with
158
- provenance, from the live agent's assembly.
159
- - `sessions.createSystemOverride` — seed a user-scope `SYSTEM.md` from the
160
- doctrine in effect; throws when an override could never apply (host-supplied
161
- profile, or a scope that excludes user files) rather than writing a file
162
- that would never be read.
163
-
164
- > **Adding a signal.** The consuming ends fail CLOSED on purpose: both
165
- > `src/tui/runtime/daemon.ts` and `gui/src/main/chat/host.ts` end their
166
- > dispatch with `signal satisfies never`, so a new `HostSignal` kind does not
167
- > compile until it is routed explicitly. Note that this catches an unrouted
168
- > KIND, not an unconsumed CALLBACK — `onRetry` and `onModelFallback` were once
169
- > declared, decoded, and dispatched while the TUI never supplied them, which
170
- > silently removed the retry indicator and the model-fallback notice. Callbacks
171
- > whose absence is a user-visible regression are therefore REQUIRED (not
172
- > optional) in `DaemonSessionCallbacks`.
173
-
174
- ### Concurrent sends
148
+ Host → every connected client, one push channel. Source of truth:
149
+ `HostSignal` in `src/chat/host/signals.ts`.
150
+
151
+ | Signal | Purpose |
152
+ | --- | --- |
153
+ | `run:start` | A run began. |
154
+ | `run:end` | A run ended (`ok`, optional `error`). |
155
+ | `turn:end` | A turn finished; carries `TurnUsage` (footer context gauge + live cost). |
156
+ | `event` | Canonical `StreamEvent`; `echo: true` marks the sender's own send/steer echo. |
157
+ | `compacting:start` | Manual or auto-compaction began. |
158
+ | `compacting:progress` | Live summary-token estimate during an in-flight compaction. |
159
+ | `compacting:end` | Compaction finished; `effectiveTokens` (success only) is the post-compact estimate. |
160
+ | `prompt-turns` | Persisted prompt turns, reduced to `PromptTurnRef` (id + texts) for echo binding. |
161
+ | `model:fallback` | The run fell back off its primary model. |
162
+ | `work:snapshot` | Cancellable tool calls / background tasks changed. |
163
+ | `todos:snapshot` | Todos state recomputed by the daemon-side agent. |
164
+ | `artifact:recorded` | An artifact was recorded — see [ARTIFACTS.md](./ARTIFACTS.md). |
165
+ | `artifact:changed` | An artifact was deleted / refreshed — see [ARTIFACTS.md](./ARTIFACTS.md). |
166
+ | `interaction:request` | A `present_plan` / `ask_user` call is pending. |
167
+ | `interaction:resolved` | An interaction left the pending queue. |
168
+ | `approval:request` | A safe-mode approval is pending. |
169
+ | `approval:resolved` | An approval left the pending queue. |
170
+ | `sessions:changed` | Session created / updated / deleted. |
171
+ | `settings:changed` | Settings were written through the host. |
172
+ | `draft:changed` | The shared prompt draft changed (origin excluded from fan-out). |
173
+ | `draft:attachments` | The draft's composing attachments changed (origin excluded). |
174
+ | `search:changed` | The shared transcript-search state changed (origin excluded). |
175
+ | `suggest:changed` | Another window's ghost-text autocomplete resolved (origin excluded). |
176
+ | `skills:snapshot` | The daemon-side agent's active-skill set changed. |
177
+ | `mcp:auth` | A daemon-side MCP server needs (re-)authentication. |
178
+ | `stream:retry` | Provider retry in progress; `source` is `'parent'` or a retrying child's id. |
179
+ | `stream:retry:cleared` | A retried stream reached a terminal state for `source`. |
180
+ | `system-md` | Which `SYSTEM.md` override (if any) composed this session's prompt. |
181
+ | `cache:break` | A cache-prefix break was detected for the session's last request. |
182
+ | `presence:changed` | Full presence snapshot (attached windows, running, attention). |
183
+ | `hud:state` | Daemon-side extension HUD (status lines + banners); windows overlay their local HUD. |
184
+ | `hud:notify` | Daemon-side extension `ctx.tui.notify` — one-shot transcript line. |
185
+ | `queue:changed` | The shared type-ahead queue changed — broadcast to every peer, origin included. |
186
+ | `extension:signal` | Extension-bus message (`ctx.bus.emit`), private per `extensionId`, 64 KiB cap. |
187
+
188
+ `event { kind: 'session-event' }` is not a separate signal: a worker's
189
+ outcome to its main session rides the existing `event` kind as a
190
+ `StreamEvent` variant, clickable in both clients.
191
+
192
+ Cross-cutting contracts worth knowing before you touch a consumer:
193
+
194
+ - **Re-convergence guarantee**: at every run end and on reconnect, windows
195
+ replace their transcript via `sessions.loadEvents` — a signal missed to
196
+ a disconnect, attach gap, or daemon respawn reconciles at the next run
197
+ boundary.
198
+ - **User messages are part of the stream**: the host broadcasts
199
+ `user-prompt` (inside `event`) for every send/steer, before `run:start`,
200
+ tagging the sender's own copy `echo: true`. A client that appends its
201
+ own sends optimistically (the GUI) must consume exactly one pending
202
+ append per matching echo — never blanket-drop echo-tagged prompts, since
203
+ other windows' genuine sends carry the same tag (`StreamStore._pendingEcho`).
204
+ - **Drafts and search** are last-writer-wins, origin-excluded, backfilled
205
+ on attach. Shell mode rides `drafts.set`'s `shell` flag out-of-band —
206
+ never encoded into the text — so a literal leading `!` can't flip other
207
+ windows into shell-execution semantics.
208
+ - **Slash commands execute host-side** (`chat.send` / `chat.pushQueued`,
209
+ full parity incl. agent-spawning commands). The TUI's own `/` and `!`
210
+ shell still execute window-locally by design; effects converge via
211
+ `sessions.touch`.
212
+ - **Queue is host-owned**: `queue.add` queues while a run is in flight
213
+ (any window) and sends otherwise; the host drains FIFO at run end, so a
214
+ queued message still runs after the queuing window closes.
215
+
216
+ > **Adding a signal.** Both `src/tui/runtime/daemon.ts` and
217
+ > `gui/src/main/chat/host.ts` end their dispatch with
218
+ > `signal satisfies never`, so a new `HostSignal` kind fails to compile
219
+ > until routed explicitly. That catches an unrouted *kind*, not an
220
+ > unconsumed *callback* — `onModelFallback`/`onRetry`/`onRetryCleared` are
221
+ > therefore required (not optional) on `DaemonSessionCallbacks`: they were
222
+ > once dispatched while the TUI silently never supplied them, quietly
223
+ > dropping the fallback notice and the retry indicator.
224
+
225
+ ## Concurrent sends
175
226
 
176
227
  Two windows racing `chat.send` on one session is refused host-side: the
177
- loser gets an explicit error. `chat.pushQueued` is the race-free spelling —
178
- it **steers** the live run when one is active and sends otherwise.
179
-
180
- > **Cross-process run guard.** Both directions are now guarded through the
181
- > store: the canonical loop persists session-level `status: 'running'`, so
182
- > the daemon's `sendPrompt` refuses a session a local in-process window is
183
- > running, and the local TUI refuses a session the daemon is running —
184
- > each with an explicit, actionable error. A freshness bound (5 min against
185
- > `updatedAt`) means a crashed process's leftover marker never blocks new
186
- > runs; live runs keep the marker fresh through a 60s heartbeat (targeted
187
- > single-column status UPDATE — safe against concurrent turn appends), so
188
- > long quiet tool calls never make a live run look stale.
228
+ loser gets an explicit error. `chat.pushQueued` is the race-free
229
+ spelling — it steers the live run when one is active and sends otherwise.
230
+
231
+ > **Cross-process run guard.** The canonical loop persists session-level
232
+ > `status: 'running'`, so the daemon refuses a session a local in-process
233
+ > window is running, and the local TUI refuses a session the daemon is
234
+ > running — each with an explicit error. A 5-minute freshness bound
235
+ > against `updatedAt` means a crashed process's leftover marker never
236
+ > blocks new runs; a live run refreshes the marker every 60s.
189
237
 
190
238
  ## Extension bus (`ctx.bus`)
191
239
 
192
240
  A private, permission-gated, per-extension cross-window message channel.
193
241
  An extension declaring the `'ipc'` permission gets `ctx.bus` —
194
- `emit(payload, { sessionId? })` plus `onMessage(fn)`; without the
195
- permission `ctx.bus` is `undefined` and no emit path exists.
196
-
197
- **Privacy model.** Channels are private per extension id: a subscriber
198
- only ever receives its own extension's messages. The id on every wire
199
- envelope is stamped by the host from the registration identity (the
200
- `buildExtensionRegistry` entry name) — the emit surface has no field a
201
- caller could use to impersonate another extension, and the daemon
202
- re-validates inbound `extension.emit` ids against the session cwd's
203
- registry (id must be loaded AND `'ipc'`-permitted) before fanning out.
204
-
205
- **Delivery.** `emit` delivers to local subscribers synchronously
206
- (`origin: 'local'`) — in-process mode is fully functional with zero IPC —
207
- and forwards the envelope (`extension:signal`, additive v6) to peers when
208
- a transport exists. Receivers re-inject with `origin: 'remote'`, dropping
209
- ids without a local registration and deduping by `(extensionId, seq)`
210
- (bounded last-seen window) so a self-echo through a second connection of
211
- the same process, or the same signal arriving on several session
212
- connections, lands at most once. Payloads are JSON values capped at
213
- 64 KiB serialized — enforced at emit (dropped with a logger warning) and
214
- again at the daemon trust boundary.
215
-
216
- Per embedding:
217
-
218
- - **daemon** — shared bus across the per-cwd registries; emits broadcast
219
- to every peer; client `extension.emit` notifications are validated,
220
- delivered daemon-side, and re-broadcast to every peer except the origin.
221
- - **TUI** — process-global bus (`tui-bus.ts`); local mode is loopback-only.
222
- In daemon mode extensions still run in the TUI process: emits ride the
223
- active session connection's `extension.emit` notification, and every
224
- connection routes inbound `extension:signal`s back into the one bus.
225
- - **GUI** — extensions run in the main process; loopback covers all
226
- main-side subscribers and the renderer (no extension runtime) never
227
- sees these signals. In daemon mode inbound signals re-inject via the
228
- main demux, and main-side emits ride the daemon connection's
229
- `extension.emit` notification (same shape as the TUI's outbound hook).
242
+ `emit(payload, { sessionId? })` plus `onMessage(fn)`; otherwise `ctx.bus`
243
+ is `undefined`.
244
+
245
+ - **Privacy**: channels are private per extension id, stamped by the host
246
+ from the registration identity. The daemon re-validates inbound
247
+ `extension.emit` ids against the session cwd's registry before fanning out.
248
+ - **Delivery**: `emit` delivers to local subscribers synchronously and
249
+ forwards the envelope to peers. Receivers dedupe by `(extensionId, seq)`
250
+ (or `(extensionId, nonce, seq)` on newer peers) so a self-echo or a
251
+ multi-connection message lands at most once. Payloads cap at 64 KiB,
252
+ enforced at emit and again at the daemon boundary.
253
+ - **Per embedding**: the daemon shares one bus per cwd registry across
254
+ peers; the TUI runs a process-global bus (`tui-bus.ts`) riding the
255
+ active session connection in daemon mode; the GUI's bus lives in the
256
+ main process (the renderer has no extension runtime).
230
257
 
231
258
  ## Daemon lifecycle
232
259
 
233
- - Socket: `~/.zidane/daemon/daemon.sock` — a dedicated 0700 subdir so the socket
234
- never inherits the shared state dir's permissions (honors `ZIDANE_STORAGE_DIR` /
235
- `ZIDANE_PREFIX`), override with `ZIDANE_DAEMON_SOCKET`.
236
- - Dev builds (`ZIDANE_DEV=1`, set by every `dev` script) use `dev.sock` in the
237
- same dir: a checkout's TUI, GUI and `zidane daemon` pair up among themselves
238
- and never attach to, drain, or get refused by the installed daemon. Sessions
239
- and settings are still shared; use `ZIDANE_PREFIX` for a fully separate
240
- instance.
241
- - **`useDaemon` setting** (Settings → agent, experimental): selects the
242
- TUI's execution runtime — in-process agent (default) vs the shared
243
- daemon. Plain `zidane` always opens the full TUI either way; the flag
244
- only decides where the agent loop runs. With it on, two terminals
245
- opening the same session converge on the same live stream: both see
246
- the run, either can steer / interrupt / answer approvals, and closing
247
- a window leaves the run executing. Window-local by design in daemon mode:
248
- the TUI's own `/` command execution and `!` shell (both converge via
249
- `sessions.touch`); slash commands sent over the wire execute host-side,
250
- and attachments sync end-to-end. Implementation:
251
- `src/tui/runtime/daemon.ts` (`connectDaemonSession`).
260
+ - Socket: `~/.zidane/daemon/daemon.sock` — a dedicated 0700 subdir
261
+ (honors `ZIDANE_STORAGE_DIR` / `ZIDANE_PREFIX`), override with
262
+ `ZIDANE_DAEMON_SOCKET`. Dev builds (`ZIDANE_DEV=1`) use `dev.sock` in the
263
+ same dir so a checkout never attaches to or drains the installed daemon.
264
+ - **`useDaemon` setting** (Settings → agent, experimental): in-process
265
+ (default) vs. the shared daemon. On, two terminals on one session share
266
+ the live stream — either can steer/interrupt/answer approvals, and
267
+ closing a window leaves the run executing. Implementation:
268
+ `connectDaemonSession` in `src/tui/runtime/daemon.ts`.
252
269
  - Start explicitly: `zidane daemon [--socket <path>] [--verbose]` or the
253
- published `zidane-daemon` bin (`--idle-exit <ms>` exits after a period
254
- with zero peers; default 30 min).
255
- - Spawn-on-demand: `connectOrSpawnDaemon()` / `zidane attach` probe the
256
- socket and spawn a detached daemon when nothing is listening.
257
- - Stale sockets: a leftover socket file from a crashed daemon is probed
258
- before unlink — a live daemon is never yanked from under its peers; a
259
- second daemon on the same socket refuses to start.
270
+ published `zidane-daemon` bin (`--idle-exit <ms>`, default 30 min, `0`
271
+ disables). `connectOrSpawnDaemon()` / `zidane attach` spawn one
272
+ on-demand when nothing is listening; a leftover socket from a crashed
273
+ daemon is probed before unlink, so a live daemon is never yanked.
260
274
  - Version skew: the first request must be `ipc.hello` carrying
261
275
  `IPC_PROTOCOL_VERSION`. On mismatch the daemon replies with a structured
262
- error (its own version attached), the client asks it to drain
263
- (`daemon.shutdown`) and respawns a fresh one.
276
+ error, the client asks it to drain (`daemon.shutdown`), and respawns.
264
277
  - **Build skew (dev)**: the handshake also carries a build fingerprint
265
- (`IPC_BUILD_ID`, the ipc module's file mtime at import). A daemon
266
- started from an older build of the SAME protocol version is rejected,
267
- drained, and respawned — so during development a rebuild or source edit
268
- automatically retires running daemons instead of leaving them serving
269
- stale code. Compiled binaries report `'unknown'` and skip the check
270
- (releases are governed by the protocol version alone).
278
+ (`IPC_BUILD_ID`, the ipc module's file mtime); an older build of the
279
+ same protocol version is rejected, drained, and respawned, so a rebuild
280
+ retires stale daemons automatically. Compiled binaries report
281
+ `'unknown'` and skip the check.
271
282
 
272
283
  ## Turning it off (containment guarantees)
273
284
 
274
- The daemon runtime is opt-in and designed to be inert when disabled:
275
-
276
- - **Default off**: `useDaemon` defaults to `false`. Every daemon touchpoint
277
- in the TUI is gated on the live controller (`daemonRef`) — with the
278
- setting off, no daemon code runs beyond one boot-time module import: no
279
- socket, no spawn, no signals, and the in-process path is byte-identical
280
- to pre-IPC behavior (send/queue/drain, settings persistence, gates).
281
- - **Cross-process run guard + heartbeat gated too**: the pre-send
282
- 'running'-marker guard and the 60s marker heartbeat (TUI local runtime
283
- AND the shared host) only engage when `useDaemon` is on. With IPC
284
- disabled there is exactly one writer per session, so the only marker a
285
- fresh process can meet is a crashed one's — ungated, that meant up to a
286
- 5-minute send lockout after an ungraceful exit mid-run. `loadSession`
287
- additionally heals stale (>5 min) on-disk 'running' headers with a
288
- targeted write, bounded so a reader can never clobber a live run's
289
- marker.
290
- - **Setting toggle**: flipping `useDaemon` off takes effect at the next
291
- session activation; teardown disposes the controller and the local
292
- runtime takes over. Detached daemons idle-exit on their own.
293
- - **GUI, same gate**: the Electron main process gates on the SAME shared
294
- `useDaemon` flag (settings live in the one `state.json`). Disabled, the
295
- GUI never probes the socket; its daemon-mode handlers all carry
296
- `if (daemon) wire / else local` branches where the local side is the
297
- embedded-host path the whole GUI test suite exercises (the suites run
298
- daemon-less), and the renderer sync surfaces are inert without signals
299
- (the shared-queue latch only engages on a first `queue:changed` and
300
- releases on `daemon:state` disconnect).
285
+ The daemon runtime is opt-in and inert when disabled:
286
+
287
+ - **Default off**: `useDaemon` defaults to `false`; every daemon touchpoint
288
+ (TUI and, on the same shared flag, GUI main) is gated on the live
289
+ controller — off, no socket, no spawn, no signals, byte-identical to
290
+ pre-IPC behavior. Toggling takes effect at the next session activation;
291
+ detached daemons idle-exit on their own.
292
+ - **Run guard + heartbeat gated too**: the pre-send running-marker guard
293
+ and its 60s heartbeat only engage when `useDaemon` is on, since with it
294
+ off there is exactly one writer per session. `loadSession` heals stale
295
+ (>5 min) on-disk `running` headers with a bounded targeted write.
301
296
  - **Emergency kill-switch**: `zidane --no-daemon` (or `ZIDANE_NO_DAEMON=1`)
302
- forces the in-process runtime for that launch regardless of the setting —
303
- usable even if the settings UI itself is unreachable. The same env var
304
- gates the GUI above its setting.
305
- - **Fail-soft activation**: if the daemon can't be reached (spawn failure,
306
- socket problem, handshake bug), session activation catches the error and
307
- falls back to the in-process runtime with a visible notice — a broken
308
- daemon path can never brick a session.
309
- - **Shared-state footprint when disabled**: none. The daemon's extra state
310
- (socket, pidfile, `pending-approvals.json`, in-memory drafts/queues)
311
- is only created by a running daemon; `sessions.db` / `state.json` access
312
- from the local TUI is unchanged.
297
+ forces the in-process runtime for both TUI and GUI regardless of the
298
+ setting.
299
+ - **Fail-soft + zero footprint**: a spawn failure, socket problem, or
300
+ handshake bug falls back to the in-process runtime with a visible
301
+ notice; the daemon's extra state (socket, pidfile,
302
+ `pending-approvals.json`, in-memory drafts/queues) exists only while a
303
+ daemon is running.
313
304
 
314
305
  ## Execution semantics (read this)
315
306
 
@@ -317,118 +308,101 @@ Moving the host out of the window process changes two things users notice:
317
308
 
318
309
  1. **Tools run in the daemon's environment.** cwd comes from the session
319
310
  (`metadata.cwd`), but env vars are the daemon's, not the terminal's.
320
- Terminal-specific env (`AWS_PROFILE` exported in one shell) does not
321
- reach daemon-hosted tools.
322
- 2. **Runs survive window close.** Detaching (`/exit`, closing the terminal)
323
- leaves the run executing in the daemon — that's the feature. Interrupt
324
- explicitly (`/interrupt`, Ctrl+C while running) to stop it.
311
+ 2. **Runs survive window close.** Detaching (`/exit`, closing the
312
+ terminal) leaves the run executing in the daemon — that's the feature.
313
+ Interrupt explicitly (`/interrupt`, Ctrl+C while running) to stop it.
325
314
 
326
315
  ## Testing
327
316
 
328
317
  - `test/ipc-host.test.ts` — host engine against a mock provider + fake
329
- broadcast: run lifecycle, gate decisions (accept/deny/denyAll/off),
330
- interaction flow, dispose/shutdown.
318
+ broadcast: run lifecycle, gate decisions, interaction flow, dispose/shutdown.
319
+ - `test/ipc-host-commands.test.ts` — host-side `/` command execution and
320
+ parity with the local runtime.
331
321
  - `test/ipc-daemon.test.ts` — real unix-socket round-trips: handshake,
332
322
  version mismatch, stale socket, multi-peer fan-out, late-attach replay,
333
- cross-window approval convergence, the concurrent-send guard, and a full
323
+ cross-window approval convergence, the concurrent-send guard, and a
334
324
  scripted `zidane attach` session.
335
325
 
336
326
  ## Surface cabling matrix (daemon runtime)
337
327
 
338
- Audited feature-by-feature. **Synced** = converges across every window via
339
- host signals; **local** = executes in the issuing window by design, effects
340
- converge through the shared store at the next run-end resync; **gap** =
341
- known missing cabling, listed with its path forward.
328
+ **Synced** = converges across every window via host signals; **local** =
329
+ executes in the issuing window by design, converges through the shared
330
+ store at the next run-end resync; **gap** = known missing cabling.
342
331
 
343
332
  | Surface | Status | How |
344
333
  | --- | --- | --- |
345
334
  | Prompt send / stream / interrupt / steer | synced | `chat.*` + `event` signals |
346
- | User messages (incl. steered/queued) | synced | host-broadcast `user-prompt` before `run:start` |
347
- | Attachments (paste/drag images + docs) | synced | `queue.add`/`chat.send` carry base64 payloads; `user-prompt` carries chip metadata (name/type/exact size) |
348
- | Type-ahead queue (queue box, push/drop) | synced | host-owned queue, `queue:*` RPC + `queue:changed` |
349
- | Prompt draft (input box content) | synced | per-keystroke `drafts.set` notifications, origin-excluded |
335
+ | User messages (incl. steered/queued) | synced | `user-prompt` broadcast before `run:start` |
336
+ | Attachments (images + docs) | synced | `queue.add`/`chat.send` carry base64; `user-prompt` carries chip metadata |
337
+ | Type-ahead queue | synced | host-owned; `queue:*` RPC + `queue:changed` |
338
+ | Prompt draft | synced | per-keystroke `drafts.set`, origin-excluded |
350
339
  | Safe-mode approvals | synced | `approval:*` signals, cross-window resolve + retraction |
351
340
  | `present_plan` / `ask_user` | synced | `interaction:*` signals, resumed orphans included |
352
- | Settings (UI + behavior) | synced | `settings:changed`; daemon is the sole `state.json` writer in daemon mode |
341
+ | Settings (UI + behavior) | synced | `settings:changed`; daemon is sole `state.json` writer in daemon mode |
353
342
  | Session list / titles / archive | synced | `sessions:changed` + store reads |
354
- | Session list "active" scope + status icons | synced | `presence.watch`/`presence.list` + `presence:changed`; each window ALSO runs an app-lifetime presence watcher (probe-only, never spawns, never counted as a window) so the list shows status before activation and across the session-switch teardown gap |
355
- | Model fallback chains (multi-model picker) | synced | `metadata.modelChain` (TUI byte format) restored host-side into multi-provider agents + `buildRunModel` pinned targets with per-target context windows; `sessions.setModel` carries `fallbacks`, `SessionRow.modelFallbacks` feeds pickers |
356
- | Compaction summarizer (`compactionModel`) + skill-edit sessions + folderless cwd (HOME anchor) | synced | ported from the GUI engine into the shared host |
357
- | Model / effort / model-options picker | synced | pick → `sessions.setModel/setEffort/setModelOptions`; session metadata is authoritative, absent metadata seeded from the window's pick |
358
- | Todos indicator + editing modal | synced | reads: `todos:snapshot` is MIRRORED into the window's session (`applyTodosSnapshot`) after a store reload for `runs` — the bag itself is never persisted, so a reload alone was always empty; `todos.get` backfills on attach. Writes: `todos.set` lands on the live agent's `Session`, and the host fans the result out. Same `chat/todo-edits` mutators on both surfaces |
359
- | Running work + ctrl+k cancel picker | synced | `work:snapshot` mirror; cancels via `chat.cancelWork` |
343
+ | Session list "active" scope + status icons | synced | `presence.*` RPC + `presence:changed` |
344
+ | Model fallback chains | synced | `metadata.modelChain`; `sessions.setModel` carries `fallbacks` |
345
+ | Compaction summarizer, skill-edit, folderless cwd | synced | ported from the GUI engine into the shared host |
346
+ | Model / effort / model-options picker | synced | `sessions.setModel/setEffort/setModelOptions` |
347
+ | Todos indicator + editing modal | synced | `todos:snapshot` mirror; `todos.set` writes the live `Session` |
348
+ | Running work + ctrl+k cancel picker | synced | `work:snapshot` mirror; cancel via `chat.cancelWork` |
360
349
  | Context panel (ctrl+?) | synced | `sessions.contextBreakdown` RPC |
361
- | Artifacts (`/artifacts`, ctrl+shift+a, sessions-list badge, `#pr` completion) | synced | reads are daemon-authoritative (`sessions.artifacts` / `artifacts.list`, falling back to the window's own store handle only on a pre-artifact daemon); `artifacts.delete` / `artifacts.upsert` fan out `artifact:changed`; the loop's `artifact:recorded` hook broadcasts the same-named signal, so an open modal refreshes the moment the agent opens a PR |
362
- | History edits (edit / delete turns via the selection modal) | synced | mutations refresh the wrapper from the store first (refused mid-run), retire the daemon's cached agent (`chat.rebuildAgent` — the next run uses the edited history), and announce via `sessions.touch` for immediate re-convergence |
363
- | History search (transcript + cross-session) | synced + accelerated | `history.searchSession`/`history.searchAll` run in the daemon over authoritative turns (fresh mid-run) with one LRU index cache shared by every window; local worker path remains the fallback |
364
- | Provider-retry indicator ("retrying n/m", parent + subagents) | synced | `stream:retry` signal; clears on resumed streaming / run end |
365
- | Run ceilings (max turns / cost / tokens / wall clock / retry budget) | synced | host reads the same settings-driven caps per run the local TUI applies |
366
- | Native server tools (e.g. Anthropic web_search) | synced | host forwards `stream:server_tool_use/result` as the same synthetic tool events replay produces |
367
- | Footer context gauge + live session cost | synced | `turn:end` signals carry `TurnUsage` (parent turns drive the gauge; parent + child turns accrue cost) |
368
- | Subagent spawn banners (start/end/error markers) | synced | host forwards `spawn:before/complete/error` as the same `spawn-start`/`spawn-end` events replay produces |
369
- | Active-skill chips | synced | `skills:snapshot` on every activation change |
370
- | MCP "needs login" badge | synced | `mcp:auth` signal; the interactive login flow runs window-local (shared credential store; daemon reconnects on next rebuild) |
350
+ | Artifacts (`/artifacts`, badges, `#pr` completion) | synced | see [ARTIFACTS.md § IPC](./ARTIFACTS.md#ipc) |
351
+ | History edits (selection modal) | synced | store refresh, `chat.rebuildAgent`, `sessions.touch` |
352
+ | History search (transcript + cross-session) | synced + accelerated | `history.searchSession`/`searchAll`, daemon LRU cache |
353
+ | Provider-retry indicator | synced | `stream:retry`; clears on resume / run end |
354
+ | Run ceilings (turns/cost/tokens/wall clock/retry) | synced | host reads the same settings-driven caps as local |
355
+ | Native server tools (e.g. web_search) | synced | host forwards `stream:server_tool_use/result` |
356
+ | Footer context gauge + live session cost | synced | `turn:end` carries `TurnUsage` |
357
+ | Subagent spawn banners | synced | host forwards `spawn:before/complete/error` |
358
+ | Active-skill chips | synced | `skills:snapshot` on activation change |
359
+ | MCP "needs login" badge | synced | `mcp:auth`; login flow itself runs window-local |
371
360
  | Manual compaction indicator | synced | `compacting:*` signals |
372
- | Transcript convergence after any miss | synced | authoritative `loadEvents` replace at every run end + reconnect |
373
- | `/` commands (incl. extension commands) | synced | HOST-SIDE execution (`src/chat/host/commands.ts`): `chat.send`/`chat.pushQueued` route slash commands in the host — full parity incl. `spawnAgents`/`runAgent` (the command spawn seam lives on the host's ActiveRun), output events fan out live to every window, command turns persist via the live Session. `commands.list`/`commands.completeArgs` serve catalogs over the wire. The TUI's daemon mode still executes its own `/` commands locally by design (UI-coupled) with `sessions.touch` convergence |
374
- | `!` shell mode | synced output | runs in the issuing window's cwd/env; the persisted call + result turns trigger `sessions.touch` → immediate convergence |
375
- | Extension tools / hooks / MCPs | synced | the daemon discovers + loads the same extension registry (same allowlist, trust, `state.json`) — extension tools run daemon-side for every window |
376
- | Agent Plugins (skills + MCP servers, `enabledPlugins` gate) | synced | the host discovers disk plugins per cwd (cached) and folds contributions into the agent it builds, and applies the SECOND plugin gate (`filterRegistryPlugins`) to the registry before composing the preset — so a plugin toggled off contributes nothing daemon-side either. Windows keep their own catalog for the settings list |
377
- | `SYSTEM.md` prompt override | synced | the host discovers it where it composes the prompt (`systemFor`), under the same `userInstructionsScope` as AGENTS.md; the winning file + shadowed candidates ride the `system-md` signal so a window can tell "none on disk" from "in force" without probing a filesystem that may not be the daemon's |
378
- | Cache-break chip (`cache:break`) + predictive `cache:drift` | synced | detection is per-agent closure state, so it is installed on the daemon's agent and forwarded as `cache:break`; cost pricing stays window-side (it needs only the provider key + token count) |
379
- | Main session (orchestration) | synced | opt-in (`orchestration`, default off). The host owns the main (`metadata.role='main'`) and its workers (`metadata.orchestratedBy`); `orchestration.main/createWorker/list/read/send/steer/cancel` RPCs, verbs scoped to the caller's own workers. A worker's `run:end` / pending approval reaches the main on two channels: a `session-event` transcript row broadcast immediately (display, safe mid-stream), and a `<session-event>` user turn + empty wake run when the main is idle (context, at the next turn boundary — buffered while running so a user turn never lands between a `tool_call` and its `tool_result`). Requires the daemon in the TUI: the local runtime has no host |
380
- | Extension UI (renderers, themes, completion, HUD chrome) | local | loaded by each window's own registry, renders broadcast events |
381
- | Extension hook-driven HUDs (status lines / banners / notify) | synced | the daemon runs a live HUD bus for extension hooks and forwards `hud:state`/`hud:notify`; windows overlay it under their local HUD (local wins per extension) |
382
- | Draft attachments (pre-submit composing view) | synced | `drafts.setAttachments` notification (payloads included, 24 MiB cap), origin-excluded fan-out, backfill on attach, cleared on submit |
383
- | Background-task wake runs | synced | host-scheduled: an idle session with pending task notifications runs a wake pass (no `user-prompt` echo) — even with zero windows attached; user interrupts latch suppression until the next send |
384
- | Steer / follow-up transcript order | synced | `user-prompt` broadcasts at DELIVERY time via the `steer:inject` hook (matched against accepted texts so system injections never render as user messages) |
385
- | MCP login → daemon credential pickup | synced | `chat.rebuildAgent` retires the daemon-side agent after a window-local OAuth login (run-end deferred) |
386
- | Cross-process run guard | synced | store-status guard in both directions with a staleness bound (see above) |
387
- | Agent-spawning `/` commands (`spawnAgents`/`runAgent`) | **gap** | need a local spawn seam without the chat agent (or a host-side spawn RPC) — the last command-parity item |
388
- | GUI windows on the shared daemon | synced | GUI main is a daemon guest under the SAME strict opt-in gate as the TUI: the shared `useDaemon` setting in `state.json` (probe-first, never spawns — the daemon needs Bun, Electron main is Node; `ZIDANE_NO_DAEMON=1` is the kill-switch above the setting); execution + HITL + queue + drafts + search + presence + settings writes ride the wire, store + settings reads stay local (same `sessions.db` / `state.json`), store mutations stay local + `sessions.touch` / `chat.rebuildAgent`; fail-soft to the embedded host with a 5s background retry |
389
- | GUI composer draft / attachments / `?` search / queue / presence badges / settings live-apply | synced | renderer surfaces bound to the daemon-mode channels; inert in-process (no signal → local behavior unchanged) |
361
+ | Transcript convergence after any miss | synced | `loadEvents` replace at run end + reconnect |
362
+ | `/` commands (incl. extension) | synced | host-side (`commands.ts`); TUI's own `/` stays local, converges via `sessions.touch` |
363
+ | `!` shell mode | synced output | runs in the issuing window's cwd/env; `sessions.touch` on persist |
364
+ | Extension tools / hooks / MCPs | synced | daemon loads the same registry; tools run daemon-side |
365
+ | Agent Plugins (`enabledPlugins`) | synced | host applies both plugin gates before composing the preset |
366
+ | `SYSTEM.md` prompt override | synced | `system-md` carries the winning file + shadowed candidates |
367
+ | Cache-break chip + predictive drift | synced | `cache:break`; cost pricing stays window-side |
368
+ | Main session (orchestration) | synced | opt-in (`orchestration`); `orchestration.*` RPCs; requires the daemon |
369
+ | Extension UI (renderers, themes, completion, HUD chrome) | local | loaded by each window's own registry |
370
+ | Extension HUDs (status/banners/notify) | synced | daemon HUD bus forwards `hud:state`/`hud:notify`, local wins |
371
+ | Draft attachments (pre-submit composing view) | synced | `drafts.setAttachments`, origin-excluded |
372
+ | Background-task wake runs | synced | host-scheduled wake pass, no windows required |
373
+ | Steer / follow-up transcript order | synced | `user-prompt` fires at delivery via the `steer:inject` hook |
374
+ | MCP login → daemon credential pickup | synced | `chat.rebuildAgent` after a window-local OAuth login |
375
+ | Cross-process run guard | synced | store-status guard both ways (see [Concurrent sends](#concurrent-sends)) |
376
+ | Agent-spawning `/` commands | **gap** | needs a local spawn seam, or a host-side spawn RPC |
377
+ | GUI windows on the shared daemon | synced | GUI main is a daemon guest under the same opt-in gate |
378
+ | GUI composer draft/attachments/search/queue/presence | synced | renderer bound to daemon-mode channels; inert in-process |
390
379
 
391
380
  ## Removability
392
381
 
393
382
  The dependency direction is strictly `src/ipc` → `src/chat`: the host and
394
- its wire shapes (`src/chat/host/**`, incl. `signals.ts`) import nothing from
395
- `src/ipc`. Deleting the whole transport — `src/ipc/**`, `src/daemon-cli.ts`,
396
- `src/tui/runtime/daemon.ts`, and the GUI's daemon glue
397
- (`gui/src/main/chat/daemon-client.ts` plus the daemon branches in
398
- `ipc.ts`/`engine.ts`/`interactions.ts`/`safeMode.ts`) — removes multi-window
399
- sync and nothing else: `createChatHost` stays, because it is the chat engine
400
- the GUI embeds unconditionally.
383
+ its wire shapes (`src/chat/host/**`, incl. `signals.ts`) import nothing
384
+ from `src/ipc`. Deleting the whole transport — `src/ipc/**`,
385
+ `src/daemon-cli.ts`, `src/tui/runtime/daemon.ts`, and the GUI's daemon
386
+ glue (`daemon-client.ts` plus the daemon branches in
387
+ `ipc.ts`/`engine.ts`/`interactions.ts`/`safeMode.ts`) — removes
388
+ multi-window sync and nothing else: `createChatHost` stays, since it's
389
+ the chat engine the GUI embeds unconditionally.
401
390
 
402
391
  ## Status / follow-ups
403
392
 
404
393
  - The default TUI (`zidane`) still runs its agent in-process; `zidane
405
- attach` is the daemon-backed terminal client. Migrating the full OpenTUI
394
+ attach` is the daemon-backed client. Migrating the full OpenTUI
406
395
  front-end onto `connectIpcClient` is the tracked follow-up.
407
- - The GUI embeds the SHARED `ChatHost` in the Electron main process
408
- (re-thinned; commands, model chains, summarizer, skill-edit, session-cwd,
409
- spawn-markers all canonical) and ALSO speaks daemon: whenever a daemon
410
- is listening AND the shared `useDaemon` setting is on,
411
- `gui/src/main/chat/daemon-client.ts` connects as a guest. The gate is
412
- the SAME strict opt-in as the TUI's — readable here because settings
413
- live in the one canonical `state.json`; the earlier socket-as-opt-in
414
- heuristic existed only for the pre-migration sqlite bootstrap gap. A
415
- manually-run `zidane daemon` with `useDaemon` off is served to explicit
416
- `zidane attach` clients but never adopted implicitly by the GUI. The
417
- gate re-evaluates live: local `settings:set`, remote
418
- `settings:changed` broadcasts, and a 5s poll all trigger
419
- connect/disconnect (`ZIDANE_NO_DAEMON=1` is the kill-switch above the
420
- setting, mirroring `--no-daemon`) (`drainOnMismatch: false`, never
421
- spawns or retires a daemon) and routes execution, HITL, queue, drafts,
422
- search, presence, and settings writes over the wire; the embedded host
423
- stays as the fail-soft fallback. Settings live in the canonical
424
- `state.json` (shared with the TUI): `settings:get` reads it locally in
425
- both modes, `settings:set` in daemon mode is WIRE-ONLY — a local
426
- pre-write would empty the daemon's before/after diff and skip its
427
- agent-teardown ladder on extension/MCP toggles — with a local-write
428
- fallback when the socket dies mid-write.
429
- Wire-less session mutations (fork / deleteTurn / editTurnText /
396
+ - The GUI embeds `ChatHost` in Electron main and also speaks daemon: when
397
+ one is listening and `useDaemon` is on, `daemon-client.ts` connects as a
398
+ guest (`drainOnMismatch: false`), routing execution, HITL, queue,
399
+ drafts, search, presence, and settings writes over the wire; the
400
+ embedded host is the fail-soft fallback.
401
+ - `settings.set` in GUI daemon mode is wire-only (a local pre-write would
402
+ skip the daemon's agent-teardown ladder on extension/MCP toggles), with
403
+ a local-write fallback if the socket dies mid-write.
404
+ - Wire-less session mutations (fork / deleteTurn / editTurnText /
430
405
  attachFolder / detachFolder) stay local-store + `sessions.touch` +
431
- `chat.rebuildAgent`, with a `chat.isRunning` refusal guard on history
406
+ `chat.rebuildAgent`, guarded by a `chat.isRunning` refusal on history
432
407
  edits. Remaining optional wire additions: paginated `sessions.loadEvents`
433
- + lazy tool-result fetch and the SQL-narrowed cross-session search fast
434
- path (perf only — correctness converges through touch/replay today).
408
+ and a SQL-narrowed cross-session search fast path (perf only).