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.
@@ -1,627 +1,176 @@
1
- # `run_in_background` — design plan
1
+ # Background tasks
2
2
 
3
- > Status: **shipped (Phase 1)**. Tracks decisions and edge cases for background shell execution. Originally written as a forward-looking plan after comparing Claude Code's task-unification refactor (`tools/BashTool/BashTool.tsx`, `tools/TaskOutputTool/`, `tools/TaskStopTool/`, `tasks/LocalShellTask/LocalShellTask.tsx`); kept as the source of truth for the design rationale. Deviations from the plan as shipped are noted inline.
3
+ Reference for backgrounded shell execution: `shell({ run_in_background: true })`, `wait_task`, `shell_kill`, and the completion-notification pipeline that wakes the model without polling. Entry points: `src/tools/shell.ts`, `src/tools/wait-task.ts`, `src/tools/shell-kill.ts`, `src/contexts/process.ts`, `src/agent.ts`. This doc owns background-task mechanics; `ARCHITECTURE.md` and `TUI.md` link here instead of re-describing it.
4
4
 
5
- ## Problem
5
+ Contents: [Model-facing tools](#model-facing-tools) · [Enabling background mode](#enabling-background-mode) · [Completion delivery](#completion-delivery) · [Wire format](#wire-format) · [ExecutionContext contract](#executioncontext-contract) · [Output file layout and caps](#output-file-layout-and-caps) · [Destroy and lifecycle](#destroy-and-lifecycle) · [Subagents](#subagents) · [TUI surface](#tui-surface) · [Per-context support](#per-context-support)
6
6
 
7
- Today's `shell` tool blocks the model's turn for the command's full duration. Correct for `git status`, broken for `npm run dev` / `python train.py` / `tail -f`. We need:
7
+ ## Model-facing tools
8
8
 
9
- - Start a long-running process and return control to the model immediately.
10
- - Let the model read incremental output without polling busy-loops.
11
- - Let the model kill a process by id.
12
- - Survive `agent.run()` boundaries (model may want to read across turns).
13
- - Tear down cleanly on `agent.destroy()` so closing the TUI doesn't orphan processes.
14
-
15
- Claude Code ships this as `Bash(command, run_in_background: true)` returning `{ backgroundTaskId, outputPath }` — output streams to a real file on disk, model reads via `Read`, framework pushes a completion notification on the next turn. We adopt that shape with minor adjustments.
16
-
17
- ## Goals
18
-
19
- 1. **Start-and-return**: `shell({ command, run_in_background: true })` settles in <100ms with `{ task_id, output_path, pid }`.
20
- 2. **Disk-backed output**: stdout + stderr stream interleaved into one file at `output_path`. Model uses the existing `read_file` tool to inspect — no new poll tool.
21
- 3. **Framework-pushed completion notification**: when a backgrounded task exits, the loop injects a `<task-notification>` block into the next user-turn so the model wakes up knowing the task is done, without polling.
22
- 4. **Process-group kill** on `shell_kill({ task_id })` — same kill-tree guarantee we already have for foreground.
23
- 5. **Agent-lifetime**, not run-lifetime — the task survives every `deactivateAllSkills` pass and run-end teardown until either it exits naturally or `agent.destroy()` fires.
24
- 6. **Per-execution-context implementation** — `ProcessContext` first, others opt in.
25
-
26
- ## Non-goals (initial scope)
27
-
28
- - **Stdin to background jobs.** Stdin is closed. Interactive REPLs and prompts (`vim`, npm prompts) are not supported. Mitigated in Phase 2 by a stall-watchdog that detects `(y/n)`-style stagnation and pushes a notification telling the model to kill + re-run with piped input or `--yes` flags.
29
- - **Persistence across TUI restarts.** Tasks die when the zidane process exits. We don't try to serialize task state to disk and reattach — that's tmux/screen territory. Resumed sessions show no live tasks.
30
- - **Cross-context teleportation.** A task started in `ProcessContext` doesn't follow if the agent swaps execution context.
31
- - **Subagent-task unification.** Claude Code's `TaskOutputTool` works on both background bash AND subagents. Conceptually elegant; structurally a different feature (async subagents). Out of scope here. Reconsider as its own RFC.
32
- - **Auto-backgrounding (KAIROS-style).** Claude Code promotes long-running foreground calls to background after a budget elapses. Magical and stateful — the model has to handle "wait, my foreground call became a task_id mid-flight". Skipped. Model decides upfront.
33
-
34
- ## High-level architecture
35
-
36
- ```
37
- ┌──────────────────────────────────────────────┐
38
- │ ExecutionContext │
39
- │ │
40
- shell ─┐ │ exec() (existing, foreground) │
41
- │ │ │
42
- shell ─┤ │ execBackground() │
43
- ({ bg }) │ │ spawn('/bin/sh', ['-c', cmd], detached) │
44
- ├─▶│ stdout/stderr → write stream → file │
45
- │ │ registry: Map<taskId, TaskEntry> │
46
- │ │ │
47
- shell_kill ─┘ │ killBackground() │
48
- │ process.kill(-pid, 'SIGTERM') │
49
- │ │
50
- └──────┬───────────────────────────────────────┘
51
- │
52
- │ on child 'close' →
53
- ▼
54
- ┌──────────────────────────────────────────────┐
55
- │ Agent │
56
- │ │
57
- │ pendingTaskNotifications: TaskNotif[] │
58
- │ ↑ enqueued on task exit │
59
- │ ↑ drained at next run() start │
60
- │ ↑ latched off when model already │
61
- │ killed / read the task │
62
- │ │
63
- │ inject as <task-notification> in the │
64
- │ leading user-turn content block │
65
- └──────────────────────────────────────────────┘
66
- ```
67
-
68
- Two pieces of plumbing:
69
-
70
- - **`ExecutionContext`** owns the registry and the file. Same shape as foreground `exec` — just doesn't await close.
71
- - **`Agent`** owns the notification queue. When a context tells it "task `bash_1` exited", it enqueues a notification. On `run()` start (or between batches), the loop injects pending notifications into the prompt.
72
-
73
- ## API design
74
-
75
- ### Model-facing tools
9
+ | Tool | Call | Result |
10
+ |---|---|---|
11
+ | `shell` | `shell({ command, run_in_background: true })` | `Started <task_id> (pid <pid>). Output: <output_path>...` — settles as soon as the process spawns, does not wait for exit |
12
+ | `shell_kill` | `shell_kill({ task_id })` | SIGTERMs the task's process group, awaits stream flush, returns `Killed <id>: exited <code>|killed (<signal>) after <duration>.` Idempotent — a task already terminated returns the cached exit info instead of erroring |
13
+ | `wait_task` | `wait_task({ task_id, timeout_ms? })` | Blocks until the task exits or `timeout_ms` elapses (default 120_000ms, capped at 30 min). Returns the exit summary on success, or a `wait_task: timed out after ...` message (task keeps running) |
76
14
 
77
- **Two model-facing pieces of API**: one flag on `shell`, plus one new `shell_kill` tool.
15
+ No `shell_output` / `shell_list` tool. The model reads incremental output with the existing `read_file({ path: output_path, offset, limit })`; a host-side listing lives in the TUI's `ctrl+k` picker (`src/tui/cancel-tool-modal.tsx`), not a model-facing tool.
78
16
 
79
- ```ts
80
- // Modified shell tool — `run_in_background` flag toggles the return shape.
81
- shell({
82
- command: 'npm run dev',
83
- run_in_background: true, // ← new optional flag
84
- })
85
- → "Started bash_1 (pid 12345). Output: /Users/.../tasks/bash_1.20260523-024147-123.log\n\nThe task is running in the background. Read the output file with `read_file` to inspect progress; you'll receive a <task-notification> when it completes."
17
+ Foreground `shell({ command })` (no flag) is unchanged: `{ output, exit_code }`.
86
18
 
87
- // New tool — terminates a running background task by id.
88
- shell_kill({ task_id: 'bash_1' })
89
- → "Killed bash_1 (exited 143). Output: /Users/.../tasks/bash_1.20260523-024147-123.log"
90
- ```
19
+ Prompt guidance shipped in `shell`'s tool description (`src/tools/shell.ts`): end the turn after backgrounding a command, don't poll the log — the `<task-notification>` is the wake-up; use `wait_task` only when the very next step needs the result.
91
20
 
92
- The log filename embeds a per-context UTC timestamp (`YYYYMMDD-HHMMSS-mmm`) after the task id, so two contexts sharing the same `tasksDir` (TUI restart, concurrent zidane instances on the same session) never resolve to the same file. The model-facing **task id** stays short (`bash_1`) — only the filesystem path carries the suffix. The model always reads the path verbatim from the spawn result; it never reconstructs it.
93
-
94
- **No `shell_output` / `shell_list`**. The model reads incremental output via the existing `read_file({ path, offset, limit })` and lists active tasks via the TUI's manage-tasks modal (or via `shell_kill` returning a list — TBD).
95
-
96
- **Foreground return shape unchanged** — calling `shell({ command })` without the flag returns `{ output, exit_code }` as today.
97
-
98
- ### Disabling background mode
99
-
100
- Background mode is auto-disabled at the **schema level** (the `run_in_background` field is dropped from the `shell` tool's input schema AND the background-mode paragraphs are dropped from its description) when either:
101
-
102
- - `behavior.tasksDir` is **unset** — the host hasn't wired the log dir; no point advertising a flag that would only error.
103
- - `behavior.disableBackgroundTasks: true` — explicit opt-out for hosts that have `tasksDir` for some other reason but don't want the model spawning background work.
104
-
105
- The model never sees `run_in_background` in either case → no wasted turns discovering the feature doesn't apply. The runtime check in `runBackground` stays as defense-in-depth for forged inputs (a hand-crafted `{ run_in_background: true }` falls through to a clean error, not a silent fallthrough to foreground).
106
-
107
- Identity check: the auto-rewrite only fires when the registered shell tool is identity-equal to the framework's exported `shell` constant. Hosts who register a custom shell-named tool keep ownership of their spec; for explicit control, import `createShellTool({ allowBackground })` and register the tailored variant directly. The pattern mirrors `createSpawnTool` / `createToolSearchTool` — same factory shape, same lifecycle.
108
-
109
- ### `ExecutionContext` additions
110
-
111
- ```ts
112
- interface ExecutionContext {
113
- // …existing fields…
114
-
115
- /**
116
- * Start a process in the background. Settles as soon as `spawn` returns
117
- * — does NOT wait for the child to exit. Stdout + stderr stream
118
- * interleaved into a single log file at the returned `outputPath`.
119
- * Caller reads via the standard `readFile` seam.
120
- *
121
- * Optional — contexts that don't support backgrounding don't implement
122
- * it. The shell tool surfaces "background mode is not supported in
123
- * this execution context" when undefined.
124
- *
125
- * On task exit, calls `onExit` with the final status. Hosts wire this
126
- * into the agent's `pendingTaskNotifications` queue so the model gets
127
- * a push notification on its next turn (see "Completion notification"
128
- * below).
129
- */
130
- execBackground?: (
131
- handle: ExecutionHandle,
132
- command: string,
133
- options: {
134
- cwd?: string
135
- env?: Record<string, string>
136
- onExit: (final: TaskExitInfo) => void
137
- },
138
- ) => Promise<{ taskId: string, outputPath: string, pid: number }>
139
-
140
- /** SIGTERM the whole process group. Idempotent — second kill is a no-op. */
141
- killBackground?: (
142
- handle: ExecutionHandle,
143
- taskId: string,
144
- ) => Promise<TaskExitInfo>
145
-
146
- /** Snapshot of every task (running + exited). */
147
- listBackground?: (
148
- handle: ExecutionHandle,
149
- ) => Promise<readonly TaskEntry[]>
150
- }
151
-
152
- interface TaskEntry {
153
- taskId: string
154
- pid: number
155
- command: string
156
- cwd: string
157
- startedAt: number
158
- outputPath: string
159
- status: 'running' | 'exited' | 'killed'
160
- exitCode?: number
161
- signal?: NodeJS.Signals
162
- /** Total bytes written to the output file so far. */
163
- bytesWritten: number
164
- }
165
-
166
- interface TaskExitInfo {
167
- taskId: string
168
- status: 'exited' | 'killed'
169
- exitCode: number
170
- signal?: NodeJS.Signals
171
- outputPath: string
172
- durationMs: number
173
- }
174
- ```
21
+ ## Enabling background mode
175
22
 
176
- `onExit` is the seam that wires task lifecycle into the agent. ProcessContext fires it from the `child.on('close')` handler. The agent layer translates that into a queued notification.
177
-
178
- ### Behavior knobs
179
-
180
- ```ts
181
- behavior: {
182
- // …existing…
183
-
184
- /** Cap on concurrent background tasks per context. Default: 8. */
185
- maxBackgroundTasks?: number
186
-
187
- /**
188
- * Default per-task file-size cap. When the output file grows past
189
- * this, we truncate from the head (preserving the tail — same
190
- * "tail-priority truncation" pattern shell uses). Default: 10 MiB.
191
- * Set to 0 to disable.
192
- */
193
- backgroundOutputCap?: number
194
-
195
- /**
196
- * Stall watchdog — when output stagnates for N ms AND the tail
197
- * matches an interactive-prompt regex, push a `<task-notification>`
198
- * telling the model the process is likely waiting on stdin.
199
- * Default: 45_000 (45 s). Set to 0 to disable.
200
- */
201
- backgroundStallWatchdogMs?: number
202
- }
203
- ```
23
+ `run_in_background` is stripped from the `shell` tool's input schema, and its description paragraphs dropped, when any of:
204
24
 
205
- No `backgroundOnDestroy` knob — destroy always kills. Power users who want tasks to outlive the TUI should use `tmux` / `nohup`; that's not the agent's job. (Drop from previous plan.)
25
+ - `behavior.tasksDir` is unset — no log directory wired.
26
+ - `behavior.disableBackgroundTasks: true` — explicit opt-out.
27
+ - `behavior.disableBackgroundTasks: 'non-durable'` **and** the context's `detachedTasks` capability is not `'durable'` (see [Per-context support](#per-context-support)).
28
+ - The context's `detachedTasks` capability is `'none'`.
206
29
 
207
- ### Tool aliases
30
+ The model never sees the flag when it wouldn't work — no wasted turn discovering the limitation (`src/agent.ts:3343-3384`). The runtime check inside the tool body stays as defense-in-depth against a forged `{ run_in_background: true }` on a rewritten schema.
208
31
 
209
- Add a `string[]` `aliases` field to the tool spec so future renames don't break resumed sessions or SDK consumers:
32
+ The rewrite only touches the framework's identity-equal `shell` export (`tools.shell === builtinShell`); a host registering its own shell-named tool keeps ownership of its spec. For explicit control, call `createShellTool({ allowBackground })` directly.
210
33
 
211
- ```ts
212
- interface ToolSpec {
213
- name: string
214
- description: string
215
- inputSchema: Record<string, unknown>
216
- aliases?: readonly string[] // ← new
217
- }
218
- ```
34
+ `behavior.tasksDir` resolves to `<userDir>/sessions/<sessionId>/tasks/` via `resolveTasksDir()` (`src/loop-persistence.ts`), wired in by the host at session activation. Cleanup rides the session-delete path — no separate GC.
219
35
 
220
- When the loop dispatches by tool name, it walks the alias table as a fallback. We don't ship with any renames today — but it's free future-proofing (Claude Code added it retroactively for `BashOutputTool` → `TaskOutputTool` migration; we avoid the same scramble).
36
+ ## Completion delivery
221
37
 
222
- ## Completion notification
38
+ Two feeds merge into one notification queue, because a push-only design misses remote/durable contexts that can't call back into the host from a timer:
223
39
 
224
- The single biggest UX win over polling. When a background task exits, the framework wakes the model on its next turn with a structured `<task-notification>` block.
40
+ - **Push**: `ExecutionContext.execBackground`'s optional `onExit` callback fires `background:exit`, enqueuing the exit info.
41
+ - **Pull**: at the start of every `agent.run()` with anything to reconcile, the agent additionally sweeps `ExecutionContext.listBackground` for terminated tasks the model hasn't been told about — this is what makes durable/remote contexts (which can't push) still deliver.
225
42
 
226
- ### Mechanism
43
+ The merged list passes through the mutable `background:reconcile` hook (`ctx.exits`), then each entry renders as a `<task-notification>` text block prepended to the seeded user turn, in completion order. A per-agent `notified` latch (derived at replay time by scanning session history, not just in-memory state) stops the pull sweep from re-notifying an already-acknowledged task.
227
44
 
228
- - **Per-agent queue** `pendingTaskNotifications: TaskNotification[]` initialised once in `createAgent`.
229
- - **Enqueue on exit**: ProcessContext's `onExit` callback (passed by the agent during `execBackground`) appends to the queue.
230
- - **Drain at run start**: `agent.run()`'s loop checks the queue early (before building the first turn's wire messages) and prepends `<task-notification>` blocks to the leading user-turn content.
231
- - **Latch via `task.notified`**: when the model already read the task's output file OR killed it via `shell_kill`, the latch flips and the notification is suppressed. Prevents the "task exited, model already killed it, framework still pushes a notification" double-signal that Claude Code's bug stream documents.
45
+ **Auto-wake.** `agent.hasPendingTaskNotifications` is `true` while at least one un-acknowledged exit is queued for the push side. Hosts use this to fire a drain-only `agent.run({ prompt: '' })` while idle — legal specifically when notifications are pending, otherwise a promptless resume is rejected.
232
46
 
233
- ### Auto-wake (host-driven delivery)
47
+ **Suppression.** The agent's `tool:after` listener deletes a pending entry (no notification fires) when the model already learned the outcome another way:
234
48
 
235
- The drain above is **passive** — it fires at the start of the next `run()`, which in interactive use means "whenever the user happens to type". Without help from the host, a task that exits while the agent is idle would never reach the model at all (the model ended its turn on the promise that "the notification IS the wake-up"). Two pieces close that gap:
236
-
237
- - **`agent.hasPendingTaskNotifications`** (framework): `true` while at least one un-suppressed exit is queued. The host's wake signal.
238
- - **Prompt-less wake runs** (framework): `agent.run({ prompt: '' })` is legal when notifications are pending, even though the session's trailing turn is a completed assistant message — the run-start drain seeds a user turn carrying ONLY the `<task-notification>` block(s). With nothing pending, the usual `cannot resume without prompt` guard still applies.
239
- - **TUI wiring** (host): the `background:exit` hook triggers a drain-only run when the agent is idle; the post-run follow-up drain (`drainFollowUps` in `app.tsx`) re-checks after every run so exits that landed *mid-run* are delivered as soon as the run ends. Wake runs skip the user-prompt echo (the live exit banner already painted) and are suppressed after an esc/abort until the user's next prompt — a run silently restarting itself after the user said "stop" would override their intent.
49
+ | Action | Suppresses? |
50
+ |---|---|
51
+ | `shell_kill({ task_id })` | Yes — deletes by `task_id` |
52
+ | `wait_task` that returned exit info | Yes — deletes by `task_id` |
53
+ | `wait_task` that timed out | No — task is still running, exit is still news |
54
+ | `read_file` / `read` against the task's `output_path`, timestamped **after** the task's exit | Yes — deletes by matching `outputPath` |
55
+ | `read_file` against a still-running task's log (progress check) | No — must not swallow a not-yet-happened exit |
240
56
 
241
- ### Wire format
57
+ ## Wire format
242
58
 
243
- A `<task-notification>` block prepended to the next user-turn content:
59
+ `<task-notification>` (rendered by `renderTaskNotificationXml`, `src/agent.ts`):
244
60
 
245
61
  ```xml
246
62
  <task-notification>
247
63
  <task-id>bash_1</task-id>
248
64
  <status>exited</status>
249
65
  <exit-code>0</exit-code>
250
- <output-file>/Users/.../tasks/bash_1.20260523-024147-123.log</output-file>
66
+ <command>npm run build</command>
67
+ <output-file>/Users/.../sessions/<id>/tasks/bash_1.20260523-024147-123.log</output-file>
68
+ <duration-ms>4200</duration-ms>
251
69
  <summary>npm run build (4.2s) — exited 0</summary>
252
70
  </task-notification>
253
71
  ```
254
72
 
255
- Multiple completed tasks → multiple blocks, in completion order.
256
-
257
- ### Prompt-side guidance
258
-
259
- The shell tool's description, when `run_in_background: true` is documented, includes:
260
-
261
- > If your command is long-running and you want to be notified when it finishes, set `run_in_background: true`. No sleep loop needed. You will receive a `<task-notification>` on your next turn with the output file path; `read_file` it to inspect. Do NOT poll the file in a loop while you wait — the notification IS the wake-up.
73
+ `<signal>` appears only when the task was terminated by a signal. All string fields go through `escapeXml` so a command containing `<`/`>`/`&` round-trips through persistence and replay. Multiple completed tasks render as multiple blocks in completion order.
262
74
 
263
- Borrows the wording from Claude Code's `BashTool/prompt.ts` (lines 317-319).
75
+ The optional stall watchdog (`behavior.backgroundStallWatchdogMs`, default: no watchdog) fires `background:stall` when a task produces no output for the configured window, and queues a distinct one-shot `<task-stall>` block instead — the process is **not** killed:
264
76
 
265
- ### Within-run waits
266
-
267
- The notification only fires on the **next** `agent.run()`. For "launched a 5-second build, want to read its output before responding in the same turn", the model has two choices:
268
-
269
- 1. **Inline foreground**: just don't use background mode. Run synchronously.
270
- 2. **`read_file` after a delay**: not great — sleep-loops are exactly what we're discouraging.
271
-
272
- We deliberately do NOT add a mid-run notification injection or a `wait_task` tool in Phase 1. If users hit this gap often enough that the model starts polling in loops anyway, add `wait_task({ task_id, timeout_ms })` in Phase 2 — it returns when the task exits or the timeout elapses, then the model `read_file`s the output once.
273
-
274
- ## State model
275
-
276
- Per `ExecutionContext` instance:
277
-
278
- ```ts
279
- interface TaskState {
280
- taskId: string
281
- pid: number
282
- command: string
283
- cwd: string
284
- env: Record<string, string>
285
- startedAt: number
286
-
287
- child: ChildProcess // the spawn() return — never exposed externally
288
- outputPath: string // absolute path to the log file
289
- outputStream: WriteStream // append stream piped from child's stdout+stderr
290
- bytesWritten: number
291
-
292
- status: 'running' | 'exited' | 'killed'
293
- exitCode?: number
294
- signal?: NodeJS.Signals
295
-
296
- notified: boolean // latch — true once the model has been told
297
- // (via auto-notification OR by reading/killing)
298
-
299
- onExit: (final: TaskExitInfo) => void // wired by the agent at spawn time
300
- }
77
+ ```xml
78
+ <task-stall>
79
+ <task-id>bash_1</task-id>
80
+ <command>npm run dev</command>
81
+ <output-file>/Users/.../bash_1.....log</output-file>
82
+ <stalled-for-ms>45000</stalled-for-ms>
83
+ <bytes-written>128</bytes-written>
84
+ <hint>The task is still running but has produced no output for the reported window. ...</hint>
85
+ </task-stall>
301
86
  ```
302
87
 
303
- **File layout**: per-session under `<userDir>/sessions/<sessionId>/tasks/<task-id>.<context-timestamp>.log`. Off the project tree, scoped to session lifetime, easy to clean up. The `<context-timestamp>` segment is `YYYYMMDD-HHMMSS-mmm` (UTC, millisecond precision) shared by every task spawned within the same `ExecutionContext` instance — so a directory listing groups cleanly by "which run produced these" and two contexts on the same session never write into the same file. On `agent.destroy()` we close the streams; on session delete (a TUI affordance) we remove the directory.
88
+ The watchdog re-arms after fresh output — one signal per quiet period, not a repeating alarm.
304
89
 
305
- **No ring buffer, no cursors**: the model uses `read_file({ offset, limit })` for incremental reads. The file is a normal log file; the existing read tool already does grep / range / pagination.
90
+ **Replay.** Persisted `<task-notification>` text blocks live in `session.turns`; `eventsFromTurns` re-synthesizes the structured `'task-notification'` `StreamEvent` from the raw XML on load, so a reloaded session renders the same banner the live run did, and the raw text block is not also shown (no double-paint).
306
91
 
307
- **Output cap**: tail-priority truncation when `bytesWritten > backgroundOutputCap`. When the cap trips we don't kill the process (unlike Claude Code's behavior on shell output overflow) — we just stop writing new bytes and append a one-time `…(N bytes dropped from middle)…` marker to the file. Long-running dev servers can write gigabytes of "request handled" logs without bringing us down.
92
+ ## ExecutionContext contract
308
93
 
309
- ## Lifecycle
94
+ All optional on `ExecutionContext` (`src/contexts/types.ts`) — a context implementing none of them surfaces "background mode is not supported in this execution context" when the model sets the flag.
310
95
 
311
- ```
312
- ┌────────────────────┐
313
- │ start │ shell({ command, run_in_background: true })
314
- │ │ → execBackground spawns the child
315
- │ │ → stdout+stderr piped to output file
316
- │ │ → registry entry created (status: 'running')
317
- │ │ → tool result: { task_id, output_path, pid }
318
- └──────────┬─────────┘
319
- │
320
- │ child writes to output file (no in-memory buffer)
321
- │
322
- ┌──────────▼─────────┐
323
- │ inspect │ model uses read_file({ path: output_path, … })
324
- │ │ on its own schedule; no new tool needed
325
- └──────────┬─────────┘
326
- │
327
- ├─────────────────┐
328
- │ │
329
- ┌──────────▼────┐ ┌────────▼────────┐
330
- │ exit │ │ kill │ shell_kill({ task_id })
331
- │ (natural) │ │ (user/model) │ → process.kill(-pid, SIGTERM)
332
- │ │ │ │ → status: 'killed', exitCode: 143
333
- │ status: │ │ │ → notified = true
334
- │ 'exited' │ │ │
335
- │ exitCode set │ └─────────────────┘
336
- └──────┬────────┘
337
- │
338
- │ onExit callback → agent.pendingTaskNotifications.push(…)
339
- │
340
- ┌──────▼─────────────┐
341
- │ wake-up │ next agent.run() begins
342
- │ │ → drain queue → prepend <task-notification> blocks
343
- │ │ → notified = true (idempotent)
344
- │ │ → model sees the notification + output path
345
- └────────────────────┘
346
- ```
347
-
348
- ### Interactions with existing semantics
349
-
350
- | User action | Effect on background task |
96
+ | Method | Contract |
351
97
  |---|---|
352
- | `esc abort run` | Background task **not affected**. Run-level abort tears down the in-flight foreground call, not the background registry. The model still sees `<task-notification>` on its next prompt. |
353
- | `ctrl+k` cancel tool | Same. Cancel-tool is scoped to the call that's in flight; the call that *started* a background task has already returned, so there's nothing to cancel. |
354
- | `agent.destroy()` | **All background tasks killed** (process group SIGTERM), output streams flushed and closed, registry cleared. No detach option. |
355
- | Session swap (TUI) | The previous session's tasks are killed (via the agent destroy that fires during teardown). The new session starts with an empty registry. |
356
- | Session resume after restart | No tasks survive process exit. The output files remain on disk (under the per-session directory) but the registry is empty. |
357
-
358
- ## Per-context support matrix
359
-
360
- | Context | Background support | Mechanism |
361
- |---|---|---|
362
- | `ProcessContext` | ✅ Phase 1 | spawn + file write streams + group kill |
363
- | `MockContext` | ✅ Phase 1 | Test stub — fake `TaskState` with manual `onExit` resolve |
364
- | `DockerContext` | ⏳ Phase 4 maybe | `docker exec -d` + tracking the exec instance — different primitive, needs its own design pass |
365
- | `SandboxContext` | ⏳ Provider-dependent | Some providers support detached exec; many don't |
366
-
367
- Contexts without `execBackground?` surface a clean "background mode is not supported in this execution context" error when the model sets the flag.
368
-
369
- ## TUI surface
370
-
371
- Per Phase 3 work:
98
+ | `execBackground(handle, command, { cwd?, env?, outputDir, onExit?, maxOutputBytes?, stallTimeoutMs?, onStall? })` | Spawns detached, pipes stdout+stderr into `<outputDir>/<task-id>.<context-timestamp>.log`, returns `{ taskId, outputPath, pid, ... }` as soon as `spawn` returns. `onExit` is optional — durable contexts that can't push from a timer just record the exit; the pull sweep picks it up via `listBackground` |
99
+ | `killBackground(handle, taskId)` | SIGTERMs the whole process group; resolves once the process has exited and its output stream is flushed+closed. Returns `null` on an unknown/already-cleaned-up id — idempotent |
100
+ | `listBackground(handle)` | Snapshot of every task (running + terminated) owned by `handle`. Terminated entries stay until context `destroy()` |
101
+ | `waitBackground(handle, taskId, { timeoutMs?, signal? })` | Resolves with `TaskExitInfo` on exit, or `null` on timeout/abort/unknown id. In-process contexts implement it on `onExit`; durable hosts implement it as an awakeable park |
102
+ | `reassignBackgroundTasks(fromHandle, toHandle, newOnExit?)` | Transfers ownership of every still-running task between handles, rewriting `onExit` to the new owner's callback. Returns the reassigned entries. Optional — without it, `destroy()` kills the source handle's tasks instead |
372
103
 
373
- - **Footer chip** showing running task count (`⌁ 2 tasks` style). Hides when zero. Same accent as the active-skills chip (`✦ N skills`).
374
- - **`ctrl+b` keybind** opens the manage-tasks modal AND, on second press / from the modal, backgrounds-all foreground tools. Borrows Claude Code's `backgroundAll()` semantics so one chord covers both common verbs.
375
- - **Manage-tasks modal**: list of tasks with id / command / status / runtime / output path. Per-row actions: kill, open output file in editor (OSC 8 link).
376
- - **Close-warning**: when the user tries to exit the TUI with running tasks, confirm-once dialog ("3 tasks will be killed — continue?"). One Enter to proceed, esc to cancel.
104
+ `TaskExitInfo`: `{ taskId, status: 'exited' | 'killed', exitCode, signal?, outputPath, durationMs, command }`. `exitCode` is `128 + signal-number` on signal-killed children (POSIX convention — `143` for SIGTERM).
377
105
 
378
- ## Decisions
106
+ `TaskEntry` (the `listBackground` row shape) adds `pid, cwd, startedAt, endedAt?, bytesWritten` over `TaskExitInfo`; `status` also allows `'running'` with `exitCode`/`signal`/`endedAt` unset.
379
107
 
380
- The three open questions are resolved. Tentative answers from the prior revision are now load-bearing — anything that diverges is a deliberate redesign and requires another review pass.
108
+ `DetachedTasksCapability` (`ContextCapabilities.detachedTasks`): `'none'` (no backgrounding) · `'process-lifetime'` (tasks die with the host process — `ProcessContext`) · `'durable'` (tasks survive host-process death, e.g. a remote runner behind `zidane/restate`). Contexts that predate the field are inferred as `'process-lifetime'` when they implement `execBackground`, else `'none'` (`resolveDetachedTasksCapability`).
381
109
 
382
- ### §1. Notification injection point — leading content block in next user-turn
110
+ ## Output file layout and caps
383
111
 
384
- When a background task exits, the framework prepends a `<task-notification>` block to the **next user-turn**'s content array (a plain `text` content block carrying the XML). Persisted to `session.turns` as part of that turn; survives history replay; no new block type; the model sees the same thing whether the run is live or being resumed from disk.
112
+ Log path: `<userDir>/sessions/<sessionId>/tasks/<task-id>.<context-timestamp>.log`. `<context-timestamp>` is `YYYYMMDD-HHMMSS-mmm` (UTC, millisecond precision), computed once per `ExecutionContext` instance and shared by every task it spawns:
385
113
 
386
- **TUI rendering — synthesized event, not raw XML in the transcript.** The `<task-notification>` tag would be ugly inline. Approach:
114
+ - Prevents two contexts on the same session (TUI restart, concurrent instances) from resolving to the same file — the OLD file would otherwise be reopened in append mode and scramble output.
115
+ - Sorts chronologically (`ls tasks/` reads in run order) and groups every task from one run under a shared prefix.
387
116
 
388
- 1. Add a new `StreamEvent.kind: 'task-notification'` shape with structured fields (`taskId`, `status`, `exitCode`, `outputPath`, `summary`, `durationMs`). The agent emits this synthesized event when it injects the text block — same as how `'compact-summary'` events get synthesized alongside their underlying turn block today.
389
- 2. The persisted user-turn carries the raw `<task-notification>` XML (for the model + replay correctness). The renderer uses the synthesized event for display.
390
- 3. `eventsFromTurns` (the live↔history reconciliation pass) detects the `<task-notification>` prefix on replay and re-synthesizes the event, so a reloaded session shows the same banner the live one did.
391
- 4. Banner shape: one line, theme-accented by status (`COLOR.dim` for exited-0, `COLOR.warn` for non-zero, `COLOR.error` for killed). Format: `⌁ bash_1 exited (0) · 4.2s · /Users/.../tasks/bash_1.20260523-024147-123.log`. OSC 8 hyperlink on the path so terminal-emulators that support it open the log file. Multi-task drains render as N banners in completion order.
392
- 5. The renderer must DEDUPE — if the raw text block is also visible in the transcript via the generic text-block path, we'd double-render. The detection in step 3 strips the text block from generic rendering when a structured event covers it. (Same dedupe pattern the compact-summary code already uses.)
117
+ The model-facing task id stays short (`bash_1`); only the on-disk filename carries the suffix. The model always echoes the path verbatim from the spawn result — it never reconstructs it. Filename convention regex: `TASK_LOG_FILENAME_RE` in `src/contexts/process.ts`.
393
118
 
394
- ### §2. File location — `<userDir>/sessions/<sessionId>/tasks/<task-id>.<context-timestamp>.log`
119
+ **Output cap** (`behavior.backgroundOutputCap`, default: no cap). Past the cap the process **keeps running**; further bytes are counted (for `TaskEntry.bytesWritten`) but dropped, and a one-time `<output-truncated bytes-dropped="N"/>` marker is appended when the task settles. The cap never kills a verbose long-running process.
395
120
 
396
- Under zidane's user data dir, scoped per session. Same rationale as the existing session-persistence layout. Cleanup hooks into the session-delete path — when a session is removed, its `tasks/` subdirectory goes with it. The user's project tree stays clean.
121
+ ## Destroy and lifecycle
397
122
 
398
- The `<context-timestamp>` is `YYYYMMDD-HHMMSS-mmm` in UTC, computed once at `createProcessContext()` and shared by every task in that context. Rationale:
399
-
400
- - **Collision-free across context restarts.** Without the suffix, a TUI restart on the same session would re-mint `bash_1, bash_2, …` and append into the OLD log files (`flags: 'a'`), producing scrambled output. Two zidane instances on the same session would do the same concurrently. The suffix decouples the model-facing id from the on-disk identity.
401
- - **Sortable.** Lexical sort of UTC `YYYYMMDD-HHMMSS-mmm` equals chronological sort. `ls tasks/` reads "in run order".
402
- - **Grouped per run.** Every `bash_N` in the same context shares the same timestamp segment — a human reading the directory listing sees the run boundary.
403
- - **Filesystem-safe.** Digits + hyphens only.
404
- - **Helper exports.** `formatContextTimestamp(date: Date): string` and `TASK_LOG_FILENAME_RE: RegExp` live in `src/contexts/process.ts` for tooling that wants to parse the convention.
405
-
406
- ### §3. Destroy ordering — tasks first, mirroring "tear down what happens in the session, then what the session needed"
407
-
408
- Borrowing the user's framing: kill the things that operate INSIDE the session (background tasks, pending tool cancels), then teardown the things the session DEPENDED ON (MCP connections, execution handle, skills cache). One-shot teardown — sequential, not parallel — the few ms of extra latency aren't worth the synchronization complexity.
409
-
410
- Final pseudo-code:
411
-
412
- ```ts
413
- async function destroy() {
414
- if (destroyed) return
415
- destroyed = true
416
-
417
- // ① INSIDE-the-session — work that the session was producing.
418
- await killAllBackgroundTasks() // SIGTERM groups, await close, flush + close WriteStreams
419
- toolCancels.abortAll('agent-destroyed') // flip live per-call signals + drop parked kills
420
-
421
- // ② NEEDED-for-the-session — infra the session was sitting on top of.
422
- if (mcpConnection) await mcpConnection.close()
423
- if (executionHandle) await executionContext.destroy(executionHandle)
424
- skillsCleanup()
425
- }
123
+ ```
124
+ start → shell({ run_in_background: true }) → execBackground spawns, registry entry created
125
+ inspect → model reads output_path via read_file, any time, no new tool
126
+ exit → natural close, or shell_kill (SIGTERM), or agent.destroy() teardown
127
+ → onExit fires exactly once → background:exit → queued (push) or later reconciled (pull)
128
+ wake → next agent.run() drains the queue → <task-notification> prepended to the seeded user turn
426
129
  ```
427
130
 
428
- ## Code-quality checklist
429
-
430
- A non-exhaustive list of "do not let these slip into the diff" — distilled from the kind of bugs this shape of feature tends to ship with. Each item references the part of the design that's most at risk.
431
-
432
- 1. **Notification double-fire (queue + drain race).** Enqueue on `child.on('close')`, drain at next `run()` start. The `notified: boolean` latch on `TaskState` must be set BEFORE we drain the entry, not after, otherwise a concurrent kill / read inside the same microtask can re-emit. Pin with a test that fires `shell_kill` between `onExit` and `run()` start — the kill must win, no notification injected.
433
-
434
- 2. **Stream flush race on task exit.** Node's `WriteStream` queues writes; `child.on('close')` can fire while bytes are still queued. Use `stream.end(callback)` and await the callback BEFORE flipping the entry to `'exited'`. Without this, a model that reads the file in the same turn as the notification arrives can see truncated output. Test: write 10 KB, exit, read — full content must be visible.
435
-
436
- 3. **File handle leaks on abnormal teardown.** The WriteStream must close on every exit path: natural close, kill, error, agent.destroy. Use a single `closeTask(taskId)` helper that's idempotent and always called from finally / catch / destroy. Test: spawn → kill → destroy → check no FD entries remain (Bun's `Bun.openSync` count or process resource usage).
437
-
438
- 4. **Path-traversal hygiene on task IDs.** Even though we mint `bash_${n}` (no user input), validate the id against `/^bash_\d+$/` before joining into a path. Defensive; cheap; pins the invariant.
439
-
440
- 5. **PGID-reuse race on long-uptime systems.** `process.kill(-pid, …)` after the kernel has reaped + reassigned the pid hits a different process. The existing kill-tree code catches ESRCH/EPERM; verify the new task-kill path uses the same try/catch and logs nothing on ESRCH (silent is correct — process is already gone).
441
-
442
- 6. **Notification queue not cleared on session swap.** The TUI's session-swap path tears the agent down and builds a fresh one. The new agent must NOT inherit pending notifications from the old session. Same pattern as `inFlightTools` / `activeSkillNames` — clear in the teardown handler.
443
-
444
- 7. **Don't abstract too early.** Keep `execBackground` / `killBackground` / `listBackground` as direct methods on `ExecutionContext`. NO "BackgroundTaskManager" class or "TaskRegistry" wrapper — the registry is a `Map<string, TaskState>` inside the context's closure, full stop. We can extract a class IF a second context implementation needs one; YAGNI until then.
445
-
446
- 8. **Don't duplicate cancellation semantics.** The `toolCancels` registry (`src/tool-cancel.ts`) is for FOREGROUND-tool cancellation — calls that are still awaited by the loop. Background tasks live in a SEPARATE registry. Two registries, two purposes, never collapse into one. Doing so risks the cancel/kill semantics drifting into each other.
447
-
448
- 9. **Persisted XML must round-trip cleanly.** The `<task-notification>` block is plain text inside a `text` content block. Escape any user-provided strings in the summary (the command shows up there). Use the existing `escapeXml` helper from `src/xml.ts`. Test: a command containing `<` / `>` / `&` round-trips through enqueue → inject → persist → replay without corruption.
449
-
450
- 10. **Run-end deactivate-all MUST NOT touch task notifications.** The existing skills deactivate-all at run end is a separate concern. The notification drain happens BEFORE the loop builds the first turn's wire messages — earlier in `run()` than `deactivateAllSkills`. Putting them in the same teardown bucket would cause notifications to fire on run-end (wrong — they fire on the NEXT run's start).
451
-
452
- 11. **Replay correctness.** `eventsFromTurns` must produce the same StreamEvent stream live and on replay. If we add the `task-notification` synthesized event, replay must re-synthesize it from the raw text block. Add an integration test that loads a session with a previously-persisted notification turn and asserts the event stream contains the structured event.
453
-
454
- 12. **No cross-context teleportation assumption.** The task registry lives on the ExecutionContext instance. Swapping contexts is not supported in v1 — document this. If swap happens (host code), the old context's destroy handles its tasks; the new context starts empty. No "migrate" logic.
455
-
456
- 13. **Output cap must NOT kill the process.** When the file grows past `backgroundOutputCap`, we drop bytes from the middle and append a one-time marker. Long-running dev servers don't get killed for being verbose — that's the bug we're avoiding from Claude Code's foreground-shell behavior. Test: spawn a writer that exceeds the cap, verify process is still running, verify marker appears, verify reading the file returns head + marker + tail.
457
-
458
- 14. **`onExit` callback must be at-most-once.** Multiple registration paths (close, error, abort) can race. Use a `settled: boolean` flag on the entry; gate the callback behind it. Same pattern the existing `runSingleToolDispatch`'s `settled` flag uses.
459
-
460
- 15. **TUI dedupe of synthesized event vs raw text.** When `eventsFromTurns` walks the persisted turns and finds a `<task-notification>` text block, it must emit ONLY the structured event — NOT both the text block AND the structured event. Otherwise the transcript shows the banner AND the raw XML. The compact-summary code has the same problem and solves it the same way; reuse the pattern.
461
-
462
- ## Phased rollout
463
-
464
- Each phase MUST land its code, tests, AND doc updates together. A phase isn't "done" until the public-facing surface is documented and the test suite covers the new behavior. No phase ships behind a "we'll write the docs next sprint" flag — that's how docs drift from reality (we just spent a release cycle backfilling the cancellation + skills work).
465
-
466
- ### Phase 1 — context primitive + ProcessContext + agent plumbing
467
-
468
- **Code:**
469
- - `ExecutionContext` adds `execBackground?` / `killBackground?` / `listBackground?` with `TaskEntry` / `TaskExitInfo` types.
470
- - `ProcessContext` implementation: spawn + WriteStream + group kill + idempotent `closeTask` + `onExit` wiring.
471
- - `MockContext` stub for tests.
472
- - `Agent`: `pendingTaskNotifications` queue + drain helper + injection at `run()` start. `notified` latch on `TaskState`. `killAllBackgroundTasks` helper called first in `destroy()`.
473
- - `Agent.destroy()` reordered per §3 — INSIDE-the-session work first, NEEDED-for-the-session second.
474
- - New tool: `shell_kill`. The `shell` tool gains the `run_in_background` flag.
475
- - New hooks: `background:start` (`{ taskId, pid, command }`), `background:exit` (`{ taskId, status, exitCode, outputPath, durationMs }`).
476
- - StreamEvent: new `kind: 'task-notification'` with structured fields; renderer dedupe vs the raw text block; `eventsFromTurns` re-synthesizes from persisted XML on replay.
477
- - TUI: banner rendering for the synthesized event (one-line, status-accented, OSC 8 link on output path).
478
-
479
- **Tests:**
480
- - `execBackground` settles in <100ms, returns valid path + pid.
481
- - File contents match expected stdout (interleaved with stderr).
482
- - `killBackground` kills the group — probe via `ps -A`, gated by sandbox-env detection (same skip pattern as the existing kill-tree test).
483
- - Notification injected at next `agent.run()` start with correct XML payload.
484
- - `notified` latch: `shell_kill` between exit and next `run()` suppresses the auto-notification.
485
- - `notified` latch: `read_file` against the output path between exit and next `run()` ALSO suppresses (covered by the latch flip in the read-tracking path).
486
- - `agent.destroy()` kills all running tasks AND flushes their output streams before returning. Probe via fd count (or via reading the file post-destroy and confirming the trailing bytes are present).
487
- - Multiple concurrent tasks → multiple notifications in completion order.
488
- - `eventsFromTurns` re-synthesizes `task-notification` events on replay.
489
- - TUI banner renders for both live + replayed events with no duplicate text block.
490
- - Escaped XML round-trip for commands with `<` / `>` / `&`.
491
- - Cross-test isolation: each test uses a unique session id + temp dir so artifacts don't leak.
492
-
493
- **Docs:**
494
- - `docs/SKILL.md`: new "Background tasks" subsection under the existing hooks + tools section. Covers the `run_in_background` flag, the notification flow, the file-path read pattern, the two new hooks, and the wire format of the `<task-notification>` block.
495
- - `docs/ARCHITECTURE.md`: add to the tool-execution diagram the `execBackground` branch and the notification-injection arrow at `run()` start. Add `background:start` / `background:exit` to the hook ordering reference.
496
- - `docs/TUI.md`: brief note that the synthesized `task-notification` event renders as a banner; full TUI coverage lives in Phase 3.
497
- - `README.md`: one-paragraph blurb under "Sub-agent Spawning" (sibling concept) introducing `run_in_background: true` with a 5-line example. Mention the notification flow + that polling is unnecessary.
498
-
499
- ### Phase 2 — quality-of-life
500
-
501
- **Code:**
502
- - Stall watchdog: detect 45 s of output stagnation + interactive-prompt regex match, push a one-shot notification telling the model to kill and re-run with piped input or `--yes` flags.
503
- - Output cap with tail-priority truncation when the file grows past `backgroundOutputCap` (default 10 MiB). MUST NOT kill the process — drop bytes from the middle, append a marker line.
504
- - `ToolSpec.aliases?: readonly string[]` field plumbed through the dispatcher's name-resolution path. No renames ship yet; the framework is ready.
505
-
506
- **Tests:**
507
- - Stall watchdog: write 100 B every 50 s for 5 minutes — no false positive. Write 100 B then `(y/n)` prompt then no more output for 45 s — notification fires exactly once.
508
- - Output cap: writer exceeds the cap by 10×, process stays alive, file contains head + marker + tail (middle dropped), notification fires on natural exit.
509
- - Aliases: register a tool with alias `["OldName"]`, dispatch by `OldName`, body runs.
510
-
511
- **Docs:**
512
- - `docs/SKILL.md`: extend the Background tasks section with the stall-watchdog behavior + the truncation marker. Document `behavior.backgroundStallWatchdogMs` and `behavior.backgroundOutputCap`. Add `aliases` to the `ToolSpec` reference.
513
- - `docs/ARCHITECTURE.md`: update the dispatcher path description to mention the alias fallback.
514
-
515
- ### Phase 3 — TUI integration
516
-
517
- **Code:**
518
- - Footer chip showing running task count (`⌁ N tasks`), hidden when zero. Same shape as the `✦ N skills` chip; same color accent or a distinct one (TBD during impl).
519
- - `ctrl+b` keybind action. First press opens the manage-tasks modal; second press (or modal action) backgrounds all currently-foreground bash tools via the same kill-tree mechanism repurposed for background promotion.
520
- - Manage-tasks modal: list (task id, command, status, runtime, output path), per-row kill action, per-row "open output" action (OSC 8 link or `$EDITOR`).
521
- - Close-warning: when the user requests TUI exit AND running tasks exist, confirm-once dialog with the count.
522
-
523
- **Tests:**
524
- - Footer chip updates as tasks start + exit (state-driven, no flicker).
525
- - `ctrl+b` first press opens modal; second press backgrounds all; modal kill action terminates the group.
526
- - Close-warning fires when tasks are running; doesn't fire when registry is empty.
527
-
528
- **Docs:**
529
- - `docs/TUI.md`: new "Background tasks" section. Cover the chip, the keybind, the modal, the close-warning. Document the `kind: 'task-notification'` event banner with screenshots / ASCII mockup.
530
- - `docs/CHAT.md`: if the chat package exposes any new types or events for the TUI integration, document them here.
531
-
532
- ### Phase 4 (deferred) — broader context coverage
533
-
534
- **Code:**
535
- - Docker exec backgrounding (`docker exec -d` + container exec tracking).
536
- - Sandbox-context providers with detached execution.
537
- - Subagent-task unification — bigger refactor, standalone RFC if pursued.
538
- - Orphan-reaping sweep for `ZIDANE_TASK_OWNER` env marker after a SIGKILL of the parent (the resource-leak mitigation from Risk #4).
539
-
540
- **Docs:**
541
- - Per-context tables in `docs/ARCHITECTURE.md` updated as each lands.
542
- - `docs/SKILL.md`: support matrix for which contexts honor `run_in_background`.
543
-
544
- ## Testing strategy
131
+ `agent.destroy()` teardown order (`src/agent.ts:5980+`): tear down what happened **inside** the session before what the session **depended on**.
545
132
 
546
- ### Unit (context layer)
133
+ 1. Clear the local notification queues unconditionally — a session swap that reconstructs the agent must not replay stale notifications.
134
+ 2. Abort the in-flight run's `AbortController` if one exists; flip every still-pending per-call tool cancel.
135
+ 3. Await any in-flight MCP warmup, then close the MCP connection.
136
+ 4. `executionContext.destroy(executionHandle)` — walks the context's task registry, SIGTERMs surviving process groups, flushes+closes their output streams, clears the registry. Skipped for a host-injected handle (the host owns that lifecycle).
137
+ 5. Re-clear the notification queues defensively — a task's own SIGTERM-triggered exit can re-populate the queue mid-step-4, with no future `run()` left to drain it.
547
138
 
548
- - `execBackground` returns `{ taskId, outputPath, pid }` in < 100ms.
549
- - Sequential task IDs (`bash_1`, `bash_2`).
550
- - Output file is written with interleaved stdout + stderr.
551
- - `killBackground` sends SIGTERM to the group; probe via `ps -A` (skipped gracefully when `ps` unavailable, same pattern as the existing `kills the whole process tree on abort` test).
552
- - `onExit` callback fires exactly once per task with correct status.
553
- - `listBackground` reflects state transitions.
554
- - Concurrent tasks respect `maxBackgroundTasks` cap.
555
- - Tail truncation kicks in past `backgroundOutputCap` without killing the process.
139
+ Every step is bounded by `destroyTimeoutMs`; a step that doesn't settle in time is abandoned (logged under `ZIDANE_DEBUG`) rather than hanging teardown.
556
140
 
557
- ### Integration (agent layer)
141
+ No detach-on-destroy option: destroy always kills every background task. Tasks that should outlive the zidane process are the user's job (`tmux`, `nohup`), not this feature's.
558
142
 
559
- - Notification queue: enqueue on exit, drain on next run-start, format matches the documented XML.
560
- - `notified` latch: kill / read suppresses the would-be auto-notification.
561
- - `agent.destroy()` kills all running tasks AND flushes their output streams before returning.
562
- - Multiple completed tasks → multiple notification blocks in completion order.
563
- - A model that reads the output file gets a coherent log (no missed bytes if the read happens after the close-write race).
143
+ ## Subagents
564
144
 
565
- ### E2E (Phase 3)
145
+ A `spawn`'d subagent has no "next user-turn" of its own — its `agent.run()` ends the moment the model returns. `shell({ run_in_background: true })` still works inside one; the tool description tells the model to return the `task_id` in its summary and end the turn.
566
146
 
567
- - TUI footer chip updates as tasks start and exit.
568
- - `ctrl+b` first press opens the modal; second press (or modal action) backgrounds all foreground tools.
569
- - Close-warning fires when running tasks exist.
147
+ Ownership transfer happens in `src/tools/spawn.ts`, in the child agent's `finally` block, **before** `agent.destroy()` runs on the child:
570
148
 
571
- ## Risks / known limitations
149
+ 1. If the execution context implements `reassignBackgroundTasks` and the child ran on its own handle (not `shareHandle`), every still-running task is promoted from the child's handle to the parent's.
150
+ 2. Each task's `onExit` is rewired to fire `background:exit` on the **parent's** hook bus — the original callback closed over the child's bus, about to be torn down.
151
+ 3. `background:reassign` fires on the parent's bus per reassigned task (`fromHandleId` / `toHandleId` / `childId`). The TUI drops the task's `childId` tag on reassignment so the `ctrl+k` picker shows it as parent-level and `agent.killBackgroundTask` can find it.
152
+ 4. `agent.destroy()` then runs on the child — only tasks NOT reassigned (context lacks the capability, or the reassign call threw) get killed by it.
572
153
 
573
- 1. **Process double-fork / setsid()** escapes our process group. A backgrounded command that itself daemonizes (`nginx`, some database servers) won't die under `process.kill(-pid)`. Documented limitation. Mitigation: advise users to background the actual daemon with `nohup` or `disown` if they want the lifetime decoupled from us.
154
+ A `spawn`'d child's own `background:start` / `background:exit` / `background:reassign` also bubble to the parent as `child:background:*` (same payload plus `childId` / `depth`) independent of the reassignment above — that's observability, this is ownership.
574
155
 
575
- 2. **`stdin` closed** — interactive REPLs / TUIs that expect a controlling terminal will misbehave. The stall watchdog (Phase 2) tries to detect this and tell the model to kill + retry with piped input. Not a silver bullet.
156
+ ## TUI surface
576
157
 
577
- 3. **Output truncation surprises** — when the output cap trips, the model sees a marker mid-file. The marker is loud enough that the model should notice, but a poorly-prompted model could miss it and assume incomplete output is the real output. Mitigated by setting the cap high (10 MiB default) and surfacing the marker as a structured tag (`<output-truncated bytes-dropped="…"/>`) the model can pattern-match.
158
+ There is no separate "manage tasks" modal. Background tasks share the **`ctrl+k`** ("cancel tool") picker (`CancelToolModal`, `src/tui/cancel-tool-modal.tsx`) with in-flight tool calls:
578
159
 
579
- 4. **Resource leaks on host SIGKILL** — if the zidane process is force-killed (not graceful destroy), `detached: true` children survive and become PPID 1. The user has to `pkill` manually. Could mitigate with an env-var marker (`ZIDANE_TASK_OWNER=<sessionId>`) that lets a next-launch sweep identify our orphans — deferred to Phase 4.
160
+ - Each row carries `kind: 'tool' | 'task'`. A `'task'` row's `callId` field holds the `taskId`, `tool` holds a command preview, and it renders with a `⌁` glyph (matching the notification banner) instead of `·`.
161
+ - Selecting a `'task'` row routes to `agent.killBackgroundTask(taskId)` instead of `agent.cancelTool(callId)` — a full SIGTERM-the-group kill, not a per-call abort signal flip. Returns `false` when the id is unknown, the context has no `killBackground`, or no execution handle is bound yet.
162
+ - The busy-state footer hint shows the `ctrl+k` chip labeled `cancel` / `cancel (N)` whenever tool calls or tasks are in flight, alongside `esc` (abort the whole run).
580
163
 
581
- 5. **`task_id` collision across context swaps** — sequential ids reset on `agent.destroy()`. If a session swaps execution contexts mid-flight (not a thing today, but conceivable), the new context starts at `bash_1` and could collide with notifications that reference the old context's ids. Document; ignore unless it becomes a real problem.
164
+ **Completion banner.** A synthesized `StreamEvent.kind: 'task-notification'` renders one line per completed task: `⌁ <task id> exited (0)|killed <duration> <log path>` — dim for a clean exit, warn otherwise. Reload re-synthesizes the same banner from the persisted XML (see [Wire format](#wire-format)); the raw text block is not shown separately.
582
165
 
583
- 6. **Race between `onExit` and the next `agent.run()`** — task exits, `onExit` enqueues notification, but the agent is mid-tool-batch on a *different* run. The notification waits for the run to complete, then drains at the next run-start. Correct, but means the model can sometimes see a notification one turn later than the kernel saw the exit. Acceptable.
166
+ Exiting the TUI does not prompt a separate confirmation for running tasks — the same `agent.destroy()` path in [Destroy and lifecycle](#destroy-and-lifecycle) handles teardown.
584
167
 
585
- 7. **File handle leaks on `agent.destroy()`** — must close every WriteStream before destroying the execution handle. The destroy ordering above puts task-kill first specifically for this reason; tests need to verify no file handles remain open.
168
+ ## Per-context support
586
169
 
587
- ## Implementation footprint estimate
170
+ | Context | `execBackground` | `detachedTasks` |
171
+ |---|---|---|
172
+ | `ProcessContext` | Yes — spawn + file write streams + process-group kill | `'process-lifetime'` |
173
+ | `MockContext` | Yes — test stub | — |
174
+ | Remote/durable contexts (e.g. behind `zidane/restate`) | Provider-dependent | `'durable'` when the provider's tasks survive worker crashes |
588
175
 
589
- | Layer | LOC (rough) |
590
- |---|---|
591
- | **Phase 1 — code** | |
592
- | `src/contexts/types.ts` — three method signatures + `TaskEntry` / `TaskExitInfo` types | ~50 |
593
- | `src/contexts/process.ts` — registry + spawn + file streams + group kill + `closeTask` | ~300 |
594
- | `src/contexts/sandbox.ts` / `docker.ts` — typed-undefined stubs | ~20 |
595
- | `src/agent.ts` — `pendingTaskNotifications` queue, drain at run-start, `<task-notification>` injection, destroy reorder | ~180 |
596
- | `src/tools/shell.ts` — `run_in_background` flag handling | ~80 |
597
- | `src/tools/shell-kill.ts` — new tool | ~60 |
598
- | `src/index.ts` — exports | ~10 |
599
- | `src/chat/types.ts` — `StreamEvent.kind: 'task-notification'` + fields | ~25 |
600
- | `src/chat/store.ts` (or wherever `eventsFromTurns` lives) — replay synthesis + dedupe | ~80 |
601
- | `src/tui/components.tsx` — banner renderer for the new event kind | ~80 |
602
- | **Phase 1 — tests** | |
603
- | `test/mock-context.ts` — task stubs | ~60 |
604
- | `test/background.test.ts` — context + agent + replay E2E | ~500 |
605
- | **Phase 1 — docs** | |
606
- | `docs/SKILL.md` / `docs/ARCHITECTURE.md` / `docs/TUI.md` / `README.md` | ~120 |
607
- | **Phase 2** — stall watchdog + output cap + tool aliases (code + tests + docs) | ~350 |
608
- | **Phase 3** — TUI chip + manage modal + `ctrl+b` keybind + close-warning (code + tests + docs) | ~400 |
609
-
610
- **Total** for Phases 1–3: ~2.3 KLOC. ~10 % larger than the post-redesign estimate because the synthesized `task-notification` StreamEvent + replay + banner rendering bumped Phase 1; still ~30 % smaller than the original ring-buffer / four-tool plan.
611
-
612
- ## Status
613
-
614
- **Phase 1 shipped.** `shell({ run_in_background: true })`, `shell_kill`, `agent.killBackgroundTask`, `background:start` / `background:exit` / `background:reassign` hooks, `<task-notification>` injection + replay, subagent reassignment, TUI banner + cancel-tool modal integration, log-filename timestamping. See the [test files](../test/background.test.ts) and [`docs/SKILL.md` — Background tasks](SKILL.md#background-tasks) for the as-shipped behavior.
615
-
616
- **Phase 2 shipped (long-running-jobs release).** Differences from the plan above are deliberate; the as-built behavior is documented in `docs/SKILL.md` and `docs/RESTATE.md`:
617
-
618
- - `wait_task({ task_id, timeout_ms })` — delegates to the new injectable `ExecutionContext.waitBackground` seam (in-process contexts park on `onExit`; durable hosts implement an awakeable park — `zidane/restate`'s `restateWaitBackground` packages that race-safe choreography). Successful waits suppress the next-turn notification; timeouts don't.
619
- - Stall watchdog — `behavior.backgroundStallWatchdogMs` → context-level `stallTimeoutMs`/`onStall` → `background:stall` hook → one-shot `<task-stall>` notification (re-arms on fresh output). No interactive-prompt regex; the model reads the log tail and decides.
620
- - Output cap — `behavior.backgroundOutputCap` → context-level `maxOutputBytes`. Head-priority retention (bytes already on disk stay, later bytes are dropped) with a `<output-truncated bytes-dropped="N"/>` marker at settle — simpler than the planned middle-drop, same guarantee: the process is never killed.
621
- - Pull-based exit reconcile — `onExit` is now OPTIONAL on the context contract. The agent merges the push-fed queue with a `listBackground` sweep at every `run()` start (using the new `TaskEntry.endedAt` for sweep-independent `durationMs`), exposes the merged list on the mutable `background:reconcile` hook, and latches notified task ids so re-listing a terminated task never re-notifies. Durable hosts make the sweep replay-deterministic via the `AgentRunOptions.journalReconcile` seam (journals the `listBackground` poll as one side effect); the hook then fires at a stable journal position.
622
- - Capability-aware gating — `ContextCapabilities.detachedTasks: 'none' | 'process-lifetime' | 'durable'`, `behavior.disableBackgroundTasks: 'non-durable'`, and `zidane/restate`'s `restateBehavior()` / `RestateToolOptions.detachedTasks` consulting it.
623
- - `ToolSpec.aliases` was NOT shipped (still backlog).
624
-
625
- Phase 3 (manage-tasks modal, `ctrl+b` keybind, close-warning) remains on the backlog. Reopen this doc when picking it up.
626
-
627
- Code-quality checklist (15 items) and per-phase doc-update obligations are load-bearing parts of the plan — not afterthoughts. Each phase ships code + tests + docs together, or it doesn't ship.
176
+ A context without `execBackground` surfaces the "not supported in this execution context" error at the tool boundary rather than silently falling back to foreground.