zidane 7.0.14 → 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/README.md +18 -9
- package/dist/{acp-CZpX91YE.js → acp-Bw2b_kka.js} +3 -3
- package/dist/{acp-CZpX91YE.js.map → acp-Bw2b_kka.js.map} +1 -1
- package/dist/acp-cli.js +4 -4
- package/dist/acp.d.ts +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-B1jsFaDq.js → agent-B_uQo4f1.js} +649 -48
- package/dist/agent-B_uQo4f1.js.map +1 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/{anthropic-Da39cE-d.js → anthropic-QbHqqTkV.js} +2 -2
- package/dist/{anthropic-Da39cE-d.js.map → anthropic-QbHqqTkV.js.map} +1 -1
- package/dist/{approvals-S100xf1d.js → approvals-B9aIQJRQ.js} +287 -45
- package/dist/approvals-B9aIQJRQ.js.map +1 -0
- package/dist/artifacts-CV2hUKnL.js +112 -0
- package/dist/artifacts-CV2hUKnL.js.map +1 -0
- package/dist/{auth-DfEWnkC7.js → auth-plw6npda.js} +19 -7
- package/dist/auth-plw6npda.js.map +1 -0
- package/dist/chat/pure.d.ts +3 -3
- package/dist/chat/pure.js +1 -1
- package/dist/chat.d.ts +6 -6
- package/dist/chat.js +5 -5
- package/dist/contexts/daytona.d.ts +1 -1
- package/dist/contexts/e2b.d.ts +1 -1
- package/dist/daemon-cli.js +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/eval.js +1 -1
- package/dist/extensions.d.ts +2 -2
- package/dist/extensions.js +2 -2
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +2 -2
- package/dist/{index-DFGECPfm.d.ts → index-Dc_zYpEu.d.ts} +351 -2
- package/dist/index-Dc_zYpEu.d.ts.map +1 -0
- package/dist/{index-DP7xYXr9.d.ts → index-Df2CH6bn.d.ts} +92 -7
- package/dist/index-Df2CH6bn.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +7 -6
- package/dist/{ipc-BLLscoIl.js → ipc-T_pPnHkp.js} +2 -2
- package/dist/{ipc-BLLscoIl.js.map → ipc-T_pPnHkp.js.map} +1 -1
- package/dist/ipc.d.ts +54 -6
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +3 -3
- package/dist/mcp.d.ts +1 -1
- package/dist/{oauth-C4GvY_p1.js → oauth-EIBUuSDK.js} +2 -2
- package/dist/{oauth-C4GvY_p1.js.map → oauth-EIBUuSDK.js.map} +1 -1
- package/dist/output/stream-json.d.ts +1 -1
- package/dist/output/terminal.d.ts +1 -1
- package/dist/plugins.d.ts +1 -1
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +2 -2
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/arcee.d.ts +1 -1
- package/dist/providers/baseten.d.ts +1 -1
- package/dist/providers/cerebras.d.ts +1 -1
- package/dist/providers/local.d.ts +1 -1
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +2 -2
- package/dist/{providers-BYgyPb15.js → providers-CoK1rZ1p.js} +2 -2
- package/dist/{providers-BYgyPb15.js.map → providers-CoK1rZ1p.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +3 -3
- package/dist/restate.d.ts +1 -1
- package/dist/restate.js +1 -1
- package/dist/{server-C8gULJ0d.js → server-DXM8ZbGi.js} +64 -6
- package/dist/server-DXM8ZbGi.js.map +1 -0
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/session/sqlite.d.ts.map +1 -1
- package/dist/session/sqlite.js +117 -1
- package/dist/session/sqlite.js.map +1 -1
- package/dist/{session-K3icbPH9.js → session-DPZnncaQ.js} +60 -1
- package/dist/session-DPZnncaQ.js.map +1 -0
- package/dist/session.d.ts +2 -2
- package/dist/session.js +3 -2
- package/dist/skills.d.ts +1 -1
- package/dist/{tool-formatters-C-s_-u0v.d.ts → tool-formatters-BgdOYAy9.d.ts} +2 -2
- package/dist/{tool-formatters-C-s_-u0v.d.ts.map → tool-formatters-BgdOYAy9.d.ts.map} +1 -1
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +2 -2
- package/dist/{transcript-anchors-D1ToF-EW.d.ts → transcript-anchors-CtnwuQJM.d.ts} +3 -3
- package/dist/transcript-anchors-CtnwuQJM.d.ts.map +1 -0
- package/dist/tui.d.ts +3 -3
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +576 -102
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-CZLsNgw-.js → turn-operations-DkJ1R0p8.js} +4 -3
- package/dist/turn-operations-DkJ1R0p8.js.map +1 -0
- package/dist/{turn-operations-Bp5-BAki.d.ts → turn-operations-khAvr7KJ.d.ts} +5 -3
- package/dist/turn-operations-khAvr7KJ.d.ts.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/{xai-DILdFGzN.js → xai-CazjStOK.js} +3 -3
- package/dist/{xai-DILdFGzN.js.map → xai-CazjStOK.js.map} +1 -1
- package/docs/ACP.md +6 -0
- package/docs/ARCHITECTURE.md +179 -356
- package/docs/ARTIFACTS.md +130 -0
- package/docs/CHAT.md +426 -1312
- package/docs/EXECUTION_CONTEXT.md +46 -19
- package/docs/EXTENSIONS.md +234 -584
- package/docs/HEADLESS.md +5 -0
- package/docs/INTERACTIONS.md +21 -22
- package/docs/IPC.md +318 -335
- package/docs/PLUGINS.md +2 -0
- package/docs/README.md +57 -0
- package/docs/RESTATE.md +199 -491
- package/docs/RUN_IN_BACKGROUND.md +107 -558
- package/docs/SKILL.md +171 -238
- package/docs/TUI.md +213 -591
- package/package.json +1 -1
- package/dist/agent-B1jsFaDq.js.map +0 -1
- package/dist/approvals-S100xf1d.js.map +0 -1
- package/dist/auth-DfEWnkC7.js.map +0 -1
- package/dist/index-DFGECPfm.d.ts.map +0 -1
- package/dist/index-DP7xYXr9.d.ts.map +0 -1
- package/dist/server-C8gULJ0d.js.map +0 -1
- package/dist/session-K3icbPH9.js.map +0 -1
- package/dist/transcript-anchors-D1ToF-EW.d.ts.map +0 -1
- package/dist/turn-operations-Bp5-BAki.d.ts.map +0 -1
- package/dist/turn-operations-CZLsNgw-.js.map +0 -1
|
@@ -1,627 +1,176 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Background tasks
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
## Model-facing tools
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
## Completion delivery
|
|
221
37
|
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
57
|
+
## Wire format
|
|
242
58
|
|
|
243
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
88
|
+
The watchdog re-arms after fresh output — one signal per quiet period, not a repeating alarm.
|
|
304
89
|
|
|
305
|
-
**
|
|
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
|
-
|
|
92
|
+
## ExecutionContext contract
|
|
308
93
|
|
|
309
|
-
|
|
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
|
-
| `
|
|
353
|
-
| `
|
|
354
|
-
| `
|
|
355
|
-
|
|
|
356
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
## Output file layout and caps
|
|
383
111
|
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
+
## Destroy and lifecycle
|
|
397
122
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
## TUI surface
|
|
576
157
|
|
|
577
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
+
## Per-context support
|
|
586
169
|
|
|
587
|
-
|
|
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
|
-
|
|
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.
|