zidane 5.4.3 → 5.5.0

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.
Files changed (76) hide show
  1. package/README.md +30 -1
  2. package/dist/{agent-Yu8uhpy-.d.ts → agent-CvImMxMQ.d.ts} +183 -3
  3. package/dist/agent-CvImMxMQ.d.ts.map +1 -0
  4. package/dist/chat.d.ts +93 -15
  5. package/dist/chat.d.ts.map +1 -1
  6. package/dist/chat.js +3 -2
  7. package/dist/contexts/docker.d.ts +1 -1
  8. package/dist/contexts-DhmMlT2W.js +472 -0
  9. package/dist/contexts-DhmMlT2W.js.map +1 -0
  10. package/dist/contexts.d.ts +3 -3
  11. package/dist/contexts.js +1 -1
  12. package/dist/{index-DklfxeYy.d.ts → index-B0uc2C5x.d.ts} +3 -3
  13. package/dist/{index-DklfxeYy.d.ts.map → index-B0uc2C5x.d.ts.map} +1 -1
  14. package/dist/{index-BiO_5Hm4.d.ts → index-CbS75MD3.d.ts} +2 -2
  15. package/dist/index-CbS75MD3.d.ts.map +1 -0
  16. package/dist/{index-j9tY28ah.d.ts → index-CtXksgqb.d.ts} +60 -4
  17. package/dist/index-CtXksgqb.d.ts.map +1 -0
  18. package/dist/index.d.ts +6 -6
  19. package/dist/index.js +8 -8
  20. package/dist/{interpolate-CmtjEyRJ.js → interpolate-BaaKaKzN.js} +2 -2
  21. package/dist/{interpolate-CmtjEyRJ.js.map → interpolate-BaaKaKzN.js.map} +1 -1
  22. package/dist/{login-DxyAERe1.js → login-iTy-0wYz.js} +2 -2
  23. package/dist/{login-DxyAERe1.js.map → login-iTy-0wYz.js.map} +1 -1
  24. package/dist/mcp.d.ts +1 -1
  25. package/dist/{presets-D9IbaI40.js → presets-h6UWhghO.js} +3 -2
  26. package/dist/presets-h6UWhghO.js.map +1 -0
  27. package/dist/presets.d.ts +2 -2
  28. package/dist/presets.js +1 -1
  29. package/dist/{providers-CEzRFYtS.js → providers-G0VBZK9j.js} +2 -2
  30. package/dist/{providers-CEzRFYtS.js.map → providers-G0VBZK9j.js.map} +1 -1
  31. package/dist/providers.d.ts +1 -1
  32. package/dist/providers.js +1 -1
  33. package/dist/session/sqlite.d.ts +1 -1
  34. package/dist/session/sqlite.d.ts.map +1 -1
  35. package/dist/session/sqlite.js +1 -0
  36. package/dist/session/sqlite.js.map +1 -1
  37. package/dist/{session-kwsNnOmt.js → session-CbkiJDlH.js} +2 -1
  38. package/dist/session-CbkiJDlH.js.map +1 -0
  39. package/dist/session.d.ts +1 -1
  40. package/dist/session.js +1 -1
  41. package/dist/skills.d.ts +2 -2
  42. package/dist/skills.js +1 -1
  43. package/dist/{tools-BK2vG9UX.js → tools-D_icxa-V.js} +668 -256
  44. package/dist/tools-D_icxa-V.js.map +1 -0
  45. package/dist/tools.d.ts +3 -3
  46. package/dist/tools.js +2 -2
  47. package/dist/{transcript-anchors-DnaBcJej.d.ts → transcript-anchors-3FFw2xuk.d.ts} +49 -10
  48. package/dist/transcript-anchors-3FFw2xuk.d.ts.map +1 -0
  49. package/dist/tui.d.ts +27 -5
  50. package/dist/tui.d.ts.map +1 -1
  51. package/dist/tui.js +239 -39
  52. package/dist/tui.js.map +1 -1
  53. package/dist/{turn-operations-OzKEOXul.js → turn-operations-CtgBlBHn.js} +178 -79
  54. package/dist/turn-operations-CtgBlBHn.js.map +1 -0
  55. package/dist/types-IcokUOyC.js.map +1 -1
  56. package/dist/types-KukEp-mi.d.ts +253 -0
  57. package/dist/types-KukEp-mi.d.ts.map +1 -0
  58. package/dist/types.d.ts +4 -4
  59. package/docs/ARCHITECTURE.md +21 -0
  60. package/docs/CHAT.md +3 -1
  61. package/docs/RUN_IN_BACKGROUND.md +612 -0
  62. package/docs/SKILL.md +59 -0
  63. package/docs/TUI.md +16 -2
  64. package/package.json +2 -2
  65. package/dist/agent-Yu8uhpy-.d.ts.map +0 -1
  66. package/dist/contexts-BwiHIr2w.js +0 -129
  67. package/dist/contexts-BwiHIr2w.js.map +0 -1
  68. package/dist/index-BiO_5Hm4.d.ts.map +0 -1
  69. package/dist/index-j9tY28ah.d.ts.map +0 -1
  70. package/dist/presets-D9IbaI40.js.map +0 -1
  71. package/dist/session-kwsNnOmt.js.map +0 -1
  72. package/dist/tools-BK2vG9UX.js.map +0 -1
  73. package/dist/transcript-anchors-DnaBcJej.d.ts.map +0 -1
  74. package/dist/turn-operations-OzKEOXul.js.map +0 -1
  75. package/dist/types-Ce78ds4h.d.ts +0 -88
  76. package/dist/types-Ce78ds4h.d.ts.map +0 -1
@@ -1,13 +1,14 @@
1
- import { n as createProcessContext } from "./contexts-BwiHIr2w.js";
1
+ import { n as createProcessContext } from "./contexts-DhmMlT2W.js";
2
2
  import { a as AgentToolPairingError, l as toTypedError, r as AgentProviderError, s as errorMessage, t as AgentAbortedError } from "./errors-CDwtPIMX.js";
3
3
  import { t as toolOutputByteLength } from "./types-IcokUOyC.js";
4
4
  import { a as detectTurnInterruption, n as SYNTHETIC_TOOL_RESULT_PLACEHOLDER, o as ensureToolResultPairing, s as filterUnresolvedToolUses } from "./messages-fTR19Ga6.js";
5
5
  import { t as connectMcpServers } from "./mcp-CNUbvbsy.js";
6
- import { _ as validateResourcePath, b as createSkillActivationState, d as escapeXml, n as resolveSkills, p as installAllowedToolsGate, t as interpolateShellCommands, u as buildCatalog } from "./interpolate-CmtjEyRJ.js";
6
+ import { _ as validateResourcePath, b as createSkillActivationState, d as escapeXml, n as resolveSkills, p as installAllowedToolsGate, t as interpolateShellCommands, u as buildCatalog } from "./interpolate-BaaKaKzN.js";
7
7
  import { n as formatTokenUsage, t as flattenTurns } from "./stats-DgOvY7wd.js";
8
+ import { dirname, isAbsolute, join, resolve } from "node:path";
8
9
  import { createHooks } from "hookable";
10
+ import { homedir } from "node:os";
9
11
  import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
10
- import { dirname, isAbsolute, join, resolve } from "node:path";
11
12
  import { Buffer } from "node:buffer";
12
13
  //#region src/aliasing.ts
13
14
  /**
@@ -103,6 +104,128 @@ function rewriteMessagesToWire(messages, maps) {
103
104
  }));
104
105
  }
105
106
  //#endregion
107
+ //#region src/chat/format.ts
108
+ /** Compact token formatter — 12_415 → "12.4k", 1_234_567 → "1.23M". */
109
+ function fmtTokens(n) {
110
+ if (n < 1e3) return String(n);
111
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 2 : 1)}k`;
112
+ return `${(n / 1e6).toFixed(2)}M`;
113
+ }
114
+ /** Compact relative-time formatter — "just now / 5m / 3h / 2d". */
115
+ function ageString(ts, now = Date.now()) {
116
+ const m = Math.floor((now - ts) / 6e4);
117
+ if (m < 1) return "just now";
118
+ if (m < 60) return `${m}m ago`;
119
+ const h = Math.floor(m / 60);
120
+ if (h < 24) return `${h}h ago`;
121
+ return `${Math.floor(h / 24)}d ago`;
122
+ }
123
+ /** Six-char short form of a session id for headers and lists. */
124
+ function shortId(id) {
125
+ return id.replace(/-/g, "").slice(0, 6);
126
+ }
127
+ /**
128
+ * Single-line preview of a multi-line string, capped at `max` chars and
129
+ * ellipsis-terminated when truncated.
130
+ *
131
+ * Whitespace runs (newlines, tabs, multiple spaces) collapse into one
132
+ * space so the rendered output stays on a single visual row no matter
133
+ * how the input was shaped. Used by every transcript "preview" surface
134
+ * (spawn-start task, `tool: shell (background): <command>`,
135
+ * `<task-notification>` summary line, etc.) — without the whitespace
136
+ * collapse, a 60-char `slice` on a string with an inline `\n\n` paints
137
+ * the second paragraph below the first, producing the visible
138
+ * "preview text spills onto multiple lines" bug (and, downstream,
139
+ * misaligned spawn markers when the wrapped lines collide with
140
+ * other events).
141
+ *
142
+ * Reserves one slot for the `…` so the displayed width is exactly
143
+ * `max` when truncation kicks in.
144
+ */
145
+ function previewLine(s, max) {
146
+ const single = s.replace(/\s+/g, " ").trim();
147
+ if (single.length <= max) return single;
148
+ return `${single.slice(0, max - 1)}…`;
149
+ }
150
+ /**
151
+ * Compact human-readable duration formatter shared by background-task
152
+ * surfaces (the `<task-notification>` summary, the TUI banner, the
153
+ * `shell_kill` tool result, etc.).
154
+ *
155
+ * Format ladder:
156
+ * - `< 1s` → `"Nms"`
157
+ * - `< 10s` → `"N.Ns"` (one decimal)
158
+ * - `< 1m` → `"Ns"` (whole seconds)
159
+ * - `< 1h` → `"NmNs"` / `"Nm"` when seconds round to 0
160
+ * - `≥ 1h` → `"NhNm"` / `"Nh"` when minutes round to 0
161
+ *
162
+ * Single source of truth so a 60s task renders the same across the
163
+ * model-facing XML summary and the user-facing banner. Earlier
164
+ * separate formatters disagreed (XML said `"60.0s"`, banner said `"1m"`)
165
+ * which was confusing to the user reading both side by side.
166
+ */
167
+ function formatDuration(ms) {
168
+ if (ms < 0) ms = 0;
169
+ if (ms < 1e3) return `${ms}ms`;
170
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(ms < 1e4 ? 1 : 0)}s`;
171
+ const minutes = Math.floor(ms / 6e4);
172
+ const seconds = Math.floor(ms % 6e4 / 1e3);
173
+ if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
174
+ const hours = Math.floor(minutes / 60);
175
+ const remMinutes = minutes % 60;
176
+ return remMinutes > 0 ? `${hours}h${remMinutes}m` : `${hours}h`;
177
+ }
178
+ /**
179
+ * Status label for a terminated background task — `"exited <code>"`
180
+ * for natural exits, `"killed"` (with the signal name when known)
181
+ * for our-issued SIGTERMs.
182
+ *
183
+ * Pulled out as its own function so the `<task-notification>` XML
184
+ * summary, the TUI banner header, the `shell_kill` tool result, and
185
+ * future surfaces all read the same string.
186
+ */
187
+ function formatTaskStatus(info) {
188
+ return info.status === "killed" ? `killed${info.signal ? ` (${info.signal})` : ""}` : `exited ${info.exitCode}`;
189
+ }
190
+ /**
191
+ * One-line summary of a terminated background task — the shape used by
192
+ * the `<task-notification>` XML's `<summary>` tag AND the TUI banner's
193
+ * `event.text` fallback string. Three dot-separated segments:
194
+ *
195
+ * `<command preview · status · duration>`
196
+ *
197
+ * Centralizes the format so live + replay + wire all agree, and so a
198
+ * future cosmetic tweak (separator glyph, segment ordering) lands in
199
+ * exactly one place.
200
+ */
201
+ function formatTaskSummary(info, maxCommandChars = 80) {
202
+ return `${previewLine(info.command, maxCommandChars)} · ${formatTaskStatus(info)} · ${formatDuration(info.durationMs)}`;
203
+ }
204
+ /**
205
+ * Compact an absolute path for display: replace the user's `$HOME`
206
+ * prefix with `~` (so `/Users/yael/Code/zidane` → `~/Code/zidane`),
207
+ * and optionally left-truncate with an ellipsis when the result
208
+ * still exceeds `maxWidth` (so the path's *tail* — the part the user
209
+ * recognizes — stays visible: `…/zidane` rather than `/Users/yaeluil…`).
210
+ *
211
+ * `maxWidth` is the maximum *display width* in cells. Omit to skip
212
+ * truncation. Paths outside `$HOME` are returned verbatim modulo
213
+ * truncation. The ellipsis (`…`) counts as one cell.
214
+ *
215
+ * `home` overrides `os.homedir()` for tests; production callers leave
216
+ * it undefined and pay the cheap one-syscall lookup per call.
217
+ */
218
+ function compactPath(path, maxWidth, home) {
219
+ const h = home ?? homedir();
220
+ let display = path;
221
+ if (h) {
222
+ if (path === h) display = "~";
223
+ else if (path.startsWith(`${h}/`)) display = `~${path.slice(h.length)}`;
224
+ }
225
+ if (maxWidth !== void 0 && maxWidth > 1 && display.length > maxWidth) return `…${display.slice(display.length - maxWidth + 1)}`;
226
+ return display;
227
+ }
228
+ //#endregion
106
229
  //#region src/tools/read-state.ts
107
230
  const STATE = /* @__PURE__ */ new WeakMap();
108
231
  /**
@@ -293,6 +416,20 @@ function resolvePersistDir(opts) {
293
416
  return join(opts.userDir, "tool-results", opts.sessionId);
294
417
  }
295
418
  /**
419
+ * Resolve the per-session background-tasks directory under
420
+ * `<userDir>/<sessionId>/tasks/`.
421
+ *
422
+ * The chat layer calls this at session activation and forwards the result
423
+ * via `behavior.tasksDir`. Same shape as {@link resolvePersistDir}: hosts
424
+ * get a single source of truth for "where do task log files live".
425
+ * Created on first write; cleanup is the session-delete path's job.
426
+ */
427
+ function resolveTasksDir(opts) {
428
+ if (!isAbsolute(opts.userDir)) throw new Error(`resolveTasksDir: userDir must be absolute, got "${opts.userDir}"`);
429
+ if (!opts.sessionId) throw new Error("resolveTasksDir: sessionId must be a non-empty string");
430
+ return join(opts.userDir, opts.sessionId, "tasks");
431
+ }
432
+ /**
296
433
  * Decide-and-persist for a single tool result. Pure decision + filesystem
297
434
  * side-effect; returns the new wire-level `output` string when substitution
298
435
  * happened, otherwise tells the caller to leave the result alone.
@@ -1883,7 +2020,9 @@ async function runSingleToolDispatch(ctx, call, turnId, fixed) {
1883
2020
  removeAbortListener?.();
1884
2021
  }
1885
2022
  } catch (err) {
1886
- if (perCallAbort.signal.aborted && !ctx.signal.aborted) cancelledByUser = true;
2023
+ const isOurSentinel = err instanceof Error && err.message === CANCELLED_BY_USER_SENTINEL;
2024
+ const isAbortError = err instanceof Error && err.name === "AbortError";
2025
+ if (isOurSentinel || isAbortError && perCallAbort.signal.aborted) cancelledByUser = true;
1887
2026
  else {
1888
2027
  const error = err instanceof Error ? err : new Error(String(err));
1889
2028
  const errorCtx = {
@@ -2110,7 +2249,8 @@ async function executeToolBatch(ctx, toolCalls, turnId) {
2110
2249
  const call = toolCalls[index];
2111
2250
  return executeSingleTool(childCtx, call, turnId).then(({ result }) => {
2112
2251
  results[index] = result;
2113
- if (result.isError && call.name === SHELL_TOOL_NAME && !siblingAbort.signal.aborted) siblingAbort.abort(SHELL_CASCADE_REASON);
2252
+ const isUserCancel = typeof result.content === "string" && result.content === "[Tool call cancelled by user]";
2253
+ if (result.isError && !isUserCancel && call.name === SHELL_TOOL_NAME && !siblingAbort.signal.aborted) siblingAbort.abort(SHELL_CASCADE_REASON);
2114
2254
  }, (err) => {
2115
2255
  const isAbort = siblingAbort.signal.aborted || ctx.signal.aborted || err instanceof Error && err.name === "AbortError";
2116
2256
  results[index] = {
@@ -2337,6 +2477,376 @@ function defaultBlockMessage(tool, max) {
2337
2477
  return `Tool '${tool}' has reached its per-run budget of ${max} calls; further invocations are refused.`;
2338
2478
  }
2339
2479
  //#endregion
2480
+ //#region src/tools/shell-semantics.ts
2481
+ const DEFAULT_SEMANTIC = (exitCode) => ({
2482
+ isError: exitCode !== 0,
2483
+ message: exitCode !== 0 ? `Command failed with exit code ${exitCode}` : void 0
2484
+ });
2485
+ const COMMAND_SEMANTICS = new Map([
2486
+ ["grep", (exit) => ({
2487
+ isError: exit >= 2,
2488
+ message: exit === 1 ? "No matches found" : void 0
2489
+ })],
2490
+ ["rg", (exit) => ({
2491
+ isError: exit >= 2,
2492
+ message: exit === 1 ? "No matches found" : void 0
2493
+ })],
2494
+ ["diff", (exit) => ({
2495
+ isError: exit >= 2,
2496
+ message: exit === 1 ? "Files differ" : void 0
2497
+ })],
2498
+ ["find", (exit) => ({
2499
+ isError: exit >= 2,
2500
+ message: exit === 1 ? "Some directories were inaccessible" : void 0
2501
+ })],
2502
+ ["test", (exit) => ({
2503
+ isError: exit >= 2,
2504
+ message: exit === 1 ? "Condition is false" : void 0
2505
+ })],
2506
+ ["[", (exit) => ({
2507
+ isError: exit >= 2,
2508
+ message: exit === 1 ? "Condition is false" : void 0
2509
+ })]
2510
+ ]);
2511
+ /**
2512
+ * Pick the semantic for a command line. Best-effort: walks the command from
2513
+ * right to left, taking the last segment after `|` / `&&` / `||` / `;` —
2514
+ * that's the segment whose exit code propagates. Don't depend on this for
2515
+ * security; it's a heuristic, not a parser.
2516
+ */
2517
+ function interpretShellResult(command, exitCode) {
2518
+ const base = extractTrailingCommand(command);
2519
+ return (COMMAND_SEMANTICS.get(base) ?? DEFAULT_SEMANTIC)(exitCode);
2520
+ }
2521
+ function extractTrailingCommand(command) {
2522
+ const segments = command.split(/\|\||&&|[;|\n]/);
2523
+ return (segments[segments.length - 1]?.trim() ?? command).split(/\s+/).filter((t) => !/^[A-Z_]\w*=/i.test(t))[0] ?? "";
2524
+ }
2525
+ //#endregion
2526
+ //#region src/tools/shell.ts
2527
+ /**
2528
+ * Execute a shell command in the agent's execution context.
2529
+ *
2530
+ * Truncation is **tail-priority**: when stdout+stderr combined exceeds
2531
+ * `maxOutputBytes`, the head is dropped and a marker `…(N bytes truncated
2532
+ * from head)…` is inserted before the tail. Errors and exit summaries
2533
+ * usually live at the end of output, so keeping the tail preserves the
2534
+ * model's most useful signal.
2535
+ *
2536
+ * Defaults are tuned for typical commands (build output, test runs): the
2537
+ * combined cap is 32 KiB and the per-call timeout follows the execution
2538
+ * context's own default (30 s for in-process).
2539
+ */
2540
+ const DEFAULT_MAX_OUTPUT_BYTES = 32768;
2541
+ /**
2542
+ * Best-effort read-only allow-list for the leading command token. Members
2543
+ * are commands whose stock behavior cannot mutate the workspace under any
2544
+ * argument combination — `ls`, `cat`, `pwd`, etc. Commands that *can*
2545
+ * mutate depending on flags (`find -delete`, `git tag <name>`, `tar -x`)
2546
+ * are intentionally excluded; the input-aware {@link isReadOnlyShellCommand}
2547
+ * predicate falls back to the conservative "not safe" answer for them, so
2548
+ * the scheduler barriers them.
2549
+ */
2550
+ const SHELL_READ_ONLY_COMMANDS = new Set([
2551
+ "ls",
2552
+ "cat",
2553
+ "head",
2554
+ "tail",
2555
+ "wc",
2556
+ "pwd",
2557
+ "whoami",
2558
+ "id",
2559
+ "date",
2560
+ "uname",
2561
+ "hostname",
2562
+ "tty",
2563
+ "echo",
2564
+ "printf",
2565
+ "env",
2566
+ "printenv",
2567
+ "which",
2568
+ "type",
2569
+ "command",
2570
+ "file",
2571
+ "stat",
2572
+ "grep",
2573
+ "rg",
2574
+ "ag",
2575
+ "true",
2576
+ "false",
2577
+ "test"
2578
+ ]);
2579
+ /**
2580
+ * `git` subcommands that are pure reads regardless of arguments. Excludes
2581
+ * `branch`/`tag`/`remote` (which can mutate when given a name) and
2582
+ * `config` (which writes when given a value).
2583
+ */
2584
+ const GIT_READ_ONLY_SUBCOMMANDS = new Set([
2585
+ "status",
2586
+ "log",
2587
+ "diff",
2588
+ "show",
2589
+ "blame",
2590
+ "rev-parse",
2591
+ "ls-files",
2592
+ "ls-tree",
2593
+ "cat-file",
2594
+ "reflog",
2595
+ "shortlog",
2596
+ "describe",
2597
+ "rev-list",
2598
+ "name-rev",
2599
+ "whatchanged",
2600
+ "merge-base",
2601
+ "symbolic-ref"
2602
+ ]);
2603
+ /**
2604
+ * Conservative read-only verdict for a shell command — used to opt a
2605
+ * `shell` invocation into the scheduler's concurrent fleet. Returns
2606
+ * `false` (fail-closed) on anything ambiguous so the scheduler barriers
2607
+ * it. Specifically:
2608
+ *
2609
+ * - Rejects compound commands (`;`, `&&`, `||`, `|`) and redirects (`>`,
2610
+ * `>>`, `<`) — even a pipe to a read-only sink is treated as too
2611
+ * complex to analyze.
2612
+ * - Rejects subshell / process substitution (`$(...)`, `` `...` ``,
2613
+ * `<(...)`, `>(...)`).
2614
+ * - Skips leading `VAR=value` env assignments to find the real
2615
+ * command token.
2616
+ * - Strips a possible absolute path on the command (`/usr/bin/ls` → `ls`).
2617
+ * - Allows the command iff its base name is in
2618
+ * {@link SHELL_READ_ONLY_COMMANDS} OR it's `git <subcmd>` where
2619
+ * `<subcmd>` is in {@link GIT_READ_ONLY_SUBCOMMANDS}.
2620
+ *
2621
+ * Cheap (no spawned process; regex + token scan). Safe to call from the
2622
+ * hot scheduler path.
2623
+ */
2624
+ function isReadOnlyShellCommand(command) {
2625
+ if (typeof command !== "string") return false;
2626
+ const trimmed = command.trim();
2627
+ if (trimmed === "") return false;
2628
+ if (/[<>;&|`\n]/.test(trimmed)) return false;
2629
+ if (trimmed.includes("$(") || trimmed.includes("<(") || trimmed.includes(">(")) return false;
2630
+ const tokens = trimmed.split(/\s+/);
2631
+ let i = 0;
2632
+ while (i < tokens.length && /^[A-Z_]\w*=/i.test(tokens[i])) i++;
2633
+ const head = tokens[i];
2634
+ if (!head) return false;
2635
+ const base = head.split("/").pop() ?? head;
2636
+ if (SHELL_READ_ONLY_COMMANDS.has(base)) return true;
2637
+ if (base === "git") {
2638
+ const sub = tokens[i + 1];
2639
+ return typeof sub === "string" && GIT_READ_ONLY_SUBCOMMANDS.has(sub);
2640
+ }
2641
+ return false;
2642
+ }
2643
+ /**
2644
+ * Build the `shell` tool's description text. The background-mode
2645
+ * paragraphs are appended only when `allowBackground` is true so the
2646
+ * model isn't pointed at a feature the agent has disabled.
2647
+ */
2648
+ function buildShellDescription({ allowBackground }) {
2649
+ const lines = [
2650
+ "Execute a shell command in the project root and return its combined stdout/stderr.",
2651
+ "Output is tail-priority truncated at 32 KiB by default; errors and exit-code summaries live in the tail.",
2652
+ "By default each call appends a `(exit N, Nms)` footer and surfaces non-empty stderr in a separate section even on success — set `metadata: false` to return only stdout. Set maxOutputBytes=0 to disable truncation."
2653
+ ];
2654
+ if (allowBackground) lines.push("", "Long-running commands (`npm run dev`, `python train.py`, anything that would otherwise block your turn for minutes) → `run_in_background: true`. The call returns immediately with `{ task_id, output_path, pid }`; stdout + stderr stream to the log file at `output_path`.", "", "After spawning a background task: end your current turn (do NOT keep iterating). A `<task-notification>` arrives on the agent's NEXT user-turn with the final status. Polling the log file in a loop wastes tokens and blocks your turn — the notification IS the wake-up. If you NEED to check progress immediately (rare), call `read_file({ path: output_path, ... })` exactly once and decide. To terminate, use `shell_kill({ task_id })`.", "", "When called from inside a `spawn`'d subagent: you have NO next user-turn — your `agent.run` ends as soon as you finish responding. Start the background task, return a brief summary including the `task_id`, and end your turn. Ownership of the task is transferred to the parent agent when your run finishes; the parent will see the notification on ITS next user-turn.");
2655
+ return lines.join("\n");
2656
+ }
2657
+ /**
2658
+ * Build the `shell` tool's JSON-schema. The `run_in_background` field
2659
+ * is included only when `allowBackground` is true; the `timeout` /
2660
+ * `maxOutputBytes` / `metadata` field descriptions also drop their
2661
+ * "Ignored in background mode" qualifier when there's no background
2662
+ * mode to ignore.
2663
+ */
2664
+ function buildShellInputSchema({ allowBackground }) {
2665
+ const bgQualifier = allowBackground ? " Ignored in background mode." : "";
2666
+ const bgQualifierOutput = allowBackground ? " Ignored in background mode (output streams to disk)." : "";
2667
+ const properties = {
2668
+ command: {
2669
+ type: "string",
2670
+ description: "Shell command to run."
2671
+ },
2672
+ timeout: {
2673
+ type: "integer",
2674
+ description: `Per-call timeout in milliseconds.${bgQualifier}`
2675
+ },
2676
+ maxOutputBytes: {
2677
+ type: "integer",
2678
+ description: `Truncate combined stdout+stderr beyond this many bytes. Default: 32768. Set 0 for unlimited.${bgQualifierOutput}`
2679
+ },
2680
+ metadata: {
2681
+ type: "boolean",
2682
+ description: `Append \`(exit N, Nms)\` footer and surface non-empty stderr on success. Default: true.${bgQualifier}`
2683
+ }
2684
+ };
2685
+ if (allowBackground) properties.run_in_background = {
2686
+ type: "boolean",
2687
+ description: "Start the command in the background, returning a task handle. See the tool description for the full flow."
2688
+ };
2689
+ return {
2690
+ type: "object",
2691
+ properties,
2692
+ required: ["command"]
2693
+ };
2694
+ }
2695
+ /**
2696
+ * Factory for the `shell` tool. The default exported `shell` is
2697
+ * equivalent to `createShellTool({ allowBackground: true })`. The
2698
+ * factory is the entry point hosts use when they want to override the
2699
+ * default — e.g. to ship a preset that always disables background mode
2700
+ * regardless of `behavior.tasksDir`.
2701
+ *
2702
+ * Hosts that use the framework's `createAgent` typically don't need to
2703
+ * call this directly: when `behavior.tasksDir` is unset or
2704
+ * `behavior.disableBackgroundTasks: true` is set, the agent
2705
+ * automatically rewrites the registered `shell` (if it's the
2706
+ * framework's built-in) using this factory.
2707
+ */
2708
+ function createShellTool(opts = {}) {
2709
+ const allowBackground = opts.allowBackground !== false;
2710
+ return {
2711
+ isConcurrencySafe: (input) => isReadOnlyShellCommand(input.command),
2712
+ spec: {
2713
+ name: "shell",
2714
+ description: buildShellDescription({ allowBackground }),
2715
+ inputSchema: buildShellInputSchema({ allowBackground })
2716
+ },
2717
+ async execute({ command, timeout, maxOutputBytes, metadata, run_in_background }, ctx) {
2718
+ const cmd = command;
2719
+ if (run_in_background === true) {
2720
+ if (!allowBackground) return "shell error: background mode is disabled for this agent (no `behavior.tasksDir` set, or `behavior.disableBackgroundTasks: true`). Fall back to foreground (drop `run_in_background`).";
2721
+ return runBackground(cmd, ctx);
2722
+ }
2723
+ const execOpts = { signal: ctx.signal };
2724
+ if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) execOpts.timeout = Math.max(1, Math.ceil(timeout / 1e3));
2725
+ const wantMetadata = metadata !== false;
2726
+ const startedAt = Date.now();
2727
+ const result = await ctx.execution.exec(ctx.handle, cmd, execOpts);
2728
+ const durationMs = Date.now() - startedAt;
2729
+ const cap = normalizeCap(maxOutputBytes);
2730
+ const semantic = interpretShellResult(cmd, result.exitCode);
2731
+ if (result.exitCode === 0) {
2732
+ const stdoutTail = truncateTail(result.stdout || "(no output)", cap);
2733
+ if (!wantMetadata) return stdoutTail;
2734
+ const stderrTrimmed = result.stderr.trim();
2735
+ return `${stdoutTail}${stderrTrimmed ? `\n[stderr]\n${truncateTail(stderrTrimmed, Math.min(cap, 2048))}` : ""}\n(exit 0, ${durationMs}ms)`;
2736
+ }
2737
+ if (!semantic.isError) {
2738
+ const tail = truncateTail((result.stdout || result.stderr || "").trim(), cap);
2739
+ const semanticFooter = semantic.message ? `\n(${semantic.message})` : "";
2740
+ const timingFooter = wantMetadata ? `\n(exit ${result.exitCode}, ${durationMs}ms)` : "";
2741
+ return `${tail.length > 0 ? tail : semantic.message ?? "(no output)"}${semanticFooter}${timingFooter}`;
2742
+ }
2743
+ const combined = `${result.stdout}\n${result.stderr}`.trim();
2744
+ return `${wantMetadata ? `Exit code ${result.exitCode} (${durationMs}ms)` : `Exit code ${result.exitCode}`}\n${truncateTail(combined, cap)}`;
2745
+ }
2746
+ };
2747
+ }
2748
+ /**
2749
+ * Default `shell` tool with background mode enabled.
2750
+ *
2751
+ * Most hosts use this directly via `basicTools`. When the agent's
2752
+ * `behavior.tasksDir` is unset OR `behavior.disableBackgroundTasks:
2753
+ * true` is set, `createAgent` auto-rewrites this identity to a
2754
+ * `createShellTool({ allowBackground: false })` variant so the model
2755
+ * never sees a flag it can't use. Hosts who want to bypass that
2756
+ * auto-rewrite can register a `createShellTool({ allowBackground })`
2757
+ * directly — the rewrite only fires on identity-equal references to
2758
+ * this constant.
2759
+ */
2760
+ const shell = createShellTool({ allowBackground: true });
2761
+ /**
2762
+ * Background-mode entry point for the `shell` tool. Settles fast,
2763
+ * registers an `onExit` callback that fires `background:exit` on the
2764
+ * agent's hook bus, and returns a structured one-liner to the model.
2765
+ *
2766
+ * Reachable only via the `allowBackground: true` variant; the
2767
+ * `allowBackground: false` variant short-circuits before this is
2768
+ * called and `createAgent` auto-rewrites the built-in to the
2769
+ * `false` variant when `behavior.tasksDir` is unset or
2770
+ * `behavior.disableBackgroundTasks: true` is set. The runtime checks
2771
+ * below are defense-in-depth for hosts who skip the auto-rewrite
2772
+ * (custom shell tool, run-level tools override, etc.):
2773
+ *
2774
+ * - `behavior.tasksDir` unset → host opted into the schema but
2775
+ * forgot to wire the log dir. Clean error, model can fall back to
2776
+ * foreground.
2777
+ * - `ctx.execution.execBackground` undefined → the context doesn't
2778
+ * support it (some remote sandboxes).
2779
+ * - `mkdir` on the output dir fails → the underlying filesystem
2780
+ * can't accommodate. Surface the error verbatim.
2781
+ */
2782
+ async function runBackground(command, ctx) {
2783
+ const tasksDir = ctx.behavior?.tasksDir;
2784
+ if (typeof tasksDir !== "string" || tasksDir.length === 0) return "shell error: background mode requires `behavior.tasksDir` to be set on the agent. The host has not opted into background tasks — either fall back to foreground (drop `run_in_background`) or ask the user to enable it.";
2785
+ if (!ctx.execution.execBackground) return `shell error: the active execution context (${ctx.execution.type}) does not support background tasks. Fall back to foreground (drop \`run_in_background\`).`;
2786
+ try {
2787
+ const handle = await ctx.execution.execBackground(ctx.handle, command, {
2788
+ outputDir: tasksDir,
2789
+ onExit: (info) => {
2790
+ Promise.resolve(ctx.hooks.callHook("background:exit", info)).catch((err) => {
2791
+ if (process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/shell] background:exit hook rejected: ${err instanceof Error ? err.message : String(err)}\n`);
2792
+ });
2793
+ }
2794
+ });
2795
+ Promise.resolve(ctx.hooks.callHook("background:start", {
2796
+ taskId: handle.taskId,
2797
+ pid: handle.pid,
2798
+ command,
2799
+ cwd: ctx.handle.cwd,
2800
+ outputPath: handle.outputPath,
2801
+ startedAt: Date.now()
2802
+ })).catch((err) => {
2803
+ if (process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/shell] background:start hook rejected: ${err instanceof Error ? err.message : String(err)}\n`);
2804
+ });
2805
+ const cmdPreview = previewLine(command, 60);
2806
+ const wakeupHint = (ctx.depth ?? 0) > 0 ? "You are inside a `spawn`'d subagent — you have NO next user-turn to receive the notification on. Return a brief summary INCLUDING this task_id and end your turn now. Ownership of the task transfers to the parent agent when your run completes; the parent will see the `<task-notification>` on its next user-turn." : "The task is running in the background. You'll receive a <task-notification> on your NEXT user-turn with the final status. End your current turn now — do NOT poll the output file in a loop, the notification IS the wake-up. To inspect progress, call `read_file({ path: <output> })` once. To terminate, use `shell_kill({ task_id })`.";
2807
+ return [
2808
+ `Started ${handle.taskId} (pid ${handle.pid}).`,
2809
+ ` command: ${cmdPreview}`,
2810
+ ` output: ${handle.outputPath}`,
2811
+ "",
2812
+ wakeupHint
2813
+ ].join("\n");
2814
+ } catch (err) {
2815
+ return `shell error: failed to start background task: ${err instanceof Error ? err.message : String(err)}`;
2816
+ }
2817
+ }
2818
+ function normalizeCap(value) {
2819
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_MAX_OUTPUT_BYTES;
2820
+ if (value < 0) return DEFAULT_MAX_OUTPUT_BYTES;
2821
+ return Math.floor(value);
2822
+ }
2823
+ /**
2824
+ * Tail-priority byte truncation. When `text` exceeds `cap` bytes, the head is
2825
+ * dropped and replaced with a marker. Always cuts on character boundaries (no
2826
+ * mid-codepoint splits) by walking from the end with `Buffer.byteLength`.
2827
+ *
2828
+ * `cap === 0` disables truncation. `cap` is interpreted as a UTF-8 byte budget
2829
+ * for the tail itself — the marker is added on top and may push the visible
2830
+ * length slightly past `cap`. That tradeoff is intentional: a marker that
2831
+ * always fits inside the budget would shrink the actual content displayed.
2832
+ */
2833
+ function truncateTail(text, cap) {
2834
+ if (cap === 0) return text;
2835
+ const totalBytes = Buffer.byteLength(text);
2836
+ if (totalBytes <= cap) return text;
2837
+ let bytes = 0;
2838
+ let charIdx = text.length;
2839
+ while (charIdx > 0) {
2840
+ const ch = text[charIdx - 1];
2841
+ const chBytes = Buffer.byteLength(ch);
2842
+ if (bytes + chBytes > cap) break;
2843
+ bytes += chBytes;
2844
+ charIdx--;
2845
+ }
2846
+ const tail = text.slice(charIdx);
2847
+ return `…(${totalBytes - Buffer.byteLength(tail)} bytes truncated from head)…\n${tail}`;
2848
+ }
2849
+ //#endregion
2340
2850
  //#region src/tools/binary-detect.ts
2341
2851
  /**
2342
2852
  * Heuristics for detecting binary content in UTF-8-decoded strings.
@@ -2504,7 +3014,10 @@ function createSkillsRunScriptTool(options) {
2504
3014
  if (!validated.valid) return `Error: ${validated.error}`;
2505
3015
  const cmd = [validated.absolutePath, ...args].map(alwaysQuote).join(" ");
2506
3016
  try {
2507
- const result = await ctx.execution.exec(ctx.handle, cmd, { timeout: Math.max(1, Math.round(timeoutMs / 1e3)) });
3017
+ const result = await ctx.execution.exec(ctx.handle, cmd, {
3018
+ timeout: Math.max(1, Math.round(timeoutMs / 1e3)),
3019
+ signal: ctx.signal
3020
+ });
2508
3021
  return JSON.stringify({
2509
3022
  exitCode: result.exitCode,
2510
3023
  stdout: result.stdout,
@@ -2751,6 +3264,39 @@ function createToolSearchTool(options) {
2751
3264
  }
2752
3265
  //#endregion
2753
3266
  //#region src/agent.ts
3267
+ /**
3268
+ * Authoritative list of hook event names. Kept in sync with `AgentHooks` at
3269
+ * compile time: the `satisfies` assertion below rejects any drift.
3270
+ */
3271
+ /**
3272
+ * Canonical XML wire format for a background-task completion notification.
3273
+ *
3274
+ * Rendered as a leading `text` content block in the next user-turn so
3275
+ * the model can pattern-match on the outer tag. The shape uses kebab-case
3276
+ * tags (matching zidane's other XML conventions). Every field is
3277
+ * structured — `<summary>` is a derived display string, NOT the source
3278
+ * of truth for the underlying data (replay reads `<command>` /
3279
+ * `<duration-ms>` / etc. directly, never the summary, so changing the
3280
+ * summary format can't break replay).
3281
+ *
3282
+ * Field-level escaping uses {@link escapeXml} so commands containing
3283
+ * `<` / `>` / `&` round-trip cleanly through persistence + replay.
3284
+ */
3285
+ function renderTaskNotificationXml(info) {
3286
+ const summary = formatTaskSummary(info);
3287
+ return [
3288
+ "<task-notification>",
3289
+ ` <task-id>${escapeXml(info.taskId)}</task-id>`,
3290
+ ` <status>${info.status}</status>`,
3291
+ ` <exit-code>${info.exitCode}</exit-code>`,
3292
+ ...info.signal ? [` <signal>${escapeXml(info.signal)}</signal>`] : [],
3293
+ ` <command>${escapeXml(info.command)}</command>`,
3294
+ ` <output-file>${escapeXml(info.outputPath)}</output-file>`,
3295
+ ` <duration-ms>${info.durationMs}</duration-ms>`,
3296
+ ` <summary>${escapeXml(summary)}</summary>`,
3297
+ "</task-notification>"
3298
+ ].join("\n");
3299
+ }
2754
3300
  const HOOK_EVENT_SET = new Set([
2755
3301
  "system:before",
2756
3302
  "agent:start",
@@ -2791,6 +3337,9 @@ const HOOK_EVENT_SET = new Set([
2791
3337
  "child:tool:transform",
2792
3338
  "child:tool:error",
2793
3339
  "child:tool:cancelled",
3340
+ "child:background:start",
3341
+ "child:background:exit",
3342
+ "child:background:reassign",
2794
3343
  "child:turn:after",
2795
3344
  "mcp:connect",
2796
3345
  "mcp:error",
@@ -2807,6 +3356,9 @@ const HOOK_EVENT_SET = new Set([
2807
3356
  "mcp:tool:after",
2808
3357
  "mcp:tool:transform",
2809
3358
  "mcp:tool:error",
3359
+ "background:start",
3360
+ "background:exit",
3361
+ "background:reassign",
2810
3362
  "skills:resolve",
2811
3363
  "skills:catalog",
2812
3364
  "skills:activate",
@@ -2907,6 +3459,8 @@ function resolveBehavior(agentBehavior, runBehavior) {
2907
3459
  persistThreshold: runBehavior?.persistThreshold ?? agentBehavior?.persistThreshold,
2908
3460
  persistExcludeTools: runBehavior?.persistExcludeTools ?? agentBehavior?.persistExcludeTools,
2909
3461
  persistDir: runBehavior?.persistDir ?? agentBehavior?.persistDir,
3462
+ tasksDir: runBehavior?.tasksDir ?? agentBehavior?.tasksDir,
3463
+ disableBackgroundTasks: runBehavior?.disableBackgroundTasks ?? agentBehavior?.disableBackgroundTasks,
2910
3464
  strictToolPairing: runBehavior?.strictToolPairing ?? agentBehavior?.strictToolPairing ?? false
2911
3465
  };
2912
3466
  }
@@ -3070,6 +3624,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3070
3624
  let running = false;
3071
3625
  let idleResolve;
3072
3626
  let idlePromise;
3627
+ const pendingTaskNotifications = /* @__PURE__ */ new Map();
3073
3628
  const pendingToolCancels = /* @__PURE__ */ new Map();
3074
3629
  let executionHandle = null;
3075
3630
  let mcpConnection = null;
@@ -3138,6 +3693,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3138
3693
  running = true;
3139
3694
  try {
3140
3695
  abortController = new AbortController();
3696
+ runCounter = Math.max(runCounter, initialRunCounter(session));
3141
3697
  const runId = `run_${++runCounter}`;
3142
3698
  const promptLabel = typeof options.prompt === "string" ? options.prompt : Array.isArray(options.prompt) ? options.prompt.filter((p) => p.type === "text").map((p) => p.text).join("\n") : "";
3143
3699
  session?.startRun(runId, promptLabel, {
@@ -3240,6 +3796,9 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3240
3796
  } : runBaseTools;
3241
3797
  const toolsPreSearch = {};
3242
3798
  for (const tool of Object.values(mergedWithSkills)) toolsPreSearch[tool.spec.name] = tool;
3799
+ if (toolsPreSearch.shell === shell) {
3800
+ if (!(typeof resolvedBehavior?.tasksDir === "string" && resolvedBehavior.tasksDir.length > 0 && resolvedBehavior.disableBackgroundTasks !== true)) toolsPreSearch.shell = createShellTool({ allowBackground: false });
3801
+ }
3243
3802
  const disclosure = partitionToolDisclosure(toolsPreSearch, mcpToolNames, mcpServers, toolDisclosure, toolAliases);
3244
3803
  const unlocked = new Set(disclosure.eagerCanonicalNames);
3245
3804
  const hostDefinedToolSearch = !!toolsPreSearch.tool_search;
@@ -3274,8 +3833,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3274
3833
  }
3275
3834
  const formattedTools = buildFormattedTools();
3276
3835
  const turns = [];
3277
- const isResume = session && session.turns.length > 0 && (session.runs.length > 0 || !options.prompt) && !options.parentRunId;
3278
- if (isResume) {
3836
+ if (session && session.turns.length > 0 && (session.runs.length > 0 || !options.prompt) && !options.parentRunId) {
3279
3837
  const childRunIds = new Set(session.runs.filter((r) => (r.depth ?? 0) > 0).map((r) => r.id));
3280
3838
  const resumed = childRunIds.size === 0 ? session.turns : session.turns.filter((t) => !t.runId || !childRunIds.has(t.runId));
3281
3839
  const filteredForRuntime = resumeFilteredTurns && resumed === session.turns ? resumeFilteredTurns : filterUnresolvedToolUses(resumed);
@@ -3283,19 +3841,28 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3283
3841
  }
3284
3842
  const runTurnStart = turns.length;
3285
3843
  if (options.system) await hooks.callHook("system:before", { system: options.system });
3844
+ const drainedNotifications = [];
3845
+ if (pendingTaskNotifications.size > 0) {
3846
+ for (const notif of pendingTaskNotifications.values()) drainedNotifications.push({
3847
+ type: "text",
3848
+ text: renderTaskNotificationXml(notif)
3849
+ });
3850
+ pendingTaskNotifications.clear();
3851
+ }
3852
+ let lastPersistedTurnCount = turns.length;
3286
3853
  const promptParts = canonicalizePrompt(options.prompt);
3287
- if (promptParts) {
3288
- const promptMsg = buildPromptMessage(provider, promptParts);
3854
+ if (promptParts || drainedNotifications.length > 0) {
3855
+ const promptMsg = promptParts ? buildPromptMessage(provider, promptParts) : null;
3856
+ const content = [...drainedNotifications, ...promptMsg ? promptMsg.content : []];
3289
3857
  turns.push({
3290
3858
  id: crypto.randomUUID(),
3291
3859
  runId,
3292
- role: promptMsg.role,
3293
- content: promptMsg.content,
3860
+ role: promptMsg ? promptMsg.role : "user",
3861
+ content,
3294
3862
  createdAt: Date.now()
3295
3863
  });
3296
3864
  }
3297
3865
  conversationTurns = turns;
3298
- let lastPersistedTurnCount = isResume ? session.turns.length : 0;
3299
3866
  if (session && turns.length > lastPersistedTurnCount) {
3300
3867
  const seededTurns = turns.slice(lastPersistedTurnCount);
3301
3868
  await session.appendTurns(seededTurns);
@@ -3510,6 +4077,11 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3510
4077
  controller.abort(reason ?? "user-cancelled-tool");
3511
4078
  return true;
3512
4079
  }
4080
+ async function killBackgroundTask(taskId) {
4081
+ if (!executionHandle) return false;
4082
+ if (!executionContext.killBackground) return false;
4083
+ return await executionContext.killBackground(executionHandle, taskId) !== null;
4084
+ }
3513
4085
  function steer(message) {
3514
4086
  steeringQueue.push(message);
3515
4087
  }
@@ -3524,6 +4096,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3524
4096
  conversationTurns = [];
3525
4097
  steeringQueue.length = 0;
3526
4098
  followUpQueue.length = 0;
4099
+ pendingTaskNotifications.clear();
3527
4100
  const cleared = skillActivationState.clear();
3528
4101
  for (const record of cleared) await hooks.callHook("skills:deactivate", {
3529
4102
  skill: record.skill,
@@ -3552,6 +4125,33 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3552
4125
  reason: "explicit"
3553
4126
  });
3554
4127
  }
4128
+ hooks.hook("background:exit", (ctx) => {
4129
+ pendingTaskNotifications.set(ctx.taskId, {
4130
+ taskId: ctx.taskId,
4131
+ status: ctx.status,
4132
+ exitCode: ctx.exitCode,
4133
+ ...ctx.signal ? { signal: ctx.signal } : {},
4134
+ outputPath: ctx.outputPath,
4135
+ durationMs: ctx.durationMs,
4136
+ command: ctx.command
4137
+ });
4138
+ });
4139
+ hooks.hook("tool:after", (ctx) => {
4140
+ if (ctx.name === "shell_kill") {
4141
+ const taskId = ctx.input?.task_id;
4142
+ if (typeof taskId === "string") pendingTaskNotifications.delete(taskId);
4143
+ return;
4144
+ }
4145
+ if (ctx.name === "read_file" || ctx.name === "read") {
4146
+ const rawPath = ctx.input?.path;
4147
+ if (typeof rawPath !== "string" || rawPath.length === 0) return;
4148
+ const requested = resolve(rawPath);
4149
+ for (const notif of pendingTaskNotifications.values()) if (resolve(notif.outputPath) === requested) {
4150
+ pendingTaskNotifications.delete(notif.taskId);
4151
+ return;
4152
+ }
4153
+ }
4154
+ });
3555
4155
  if (session) {
3556
4156
  const originalSave = session.save.bind(session);
3557
4157
  const originalSetMeta = session.setMeta.bind(session);
@@ -3603,6 +4203,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3603
4203
  async function destroy() {
3604
4204
  if (destroyed) return;
3605
4205
  destroyed = true;
4206
+ pendingTaskNotifications.clear();
3606
4207
  for (const controller of pendingToolCancels.values()) if (!controller.signal.aborted) controller.abort("agent-destroyed");
3607
4208
  pendingToolCancels.clear();
3608
4209
  if (mcpWarmupPromise) try {
@@ -3616,6 +4217,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3616
4217
  await executionContext.destroy(executionHandle);
3617
4218
  executionHandle = null;
3618
4219
  }
4220
+ pendingTaskNotifications.clear();
3619
4221
  skillsCleanup();
3620
4222
  skillsCleanup = () => {};
3621
4223
  }
@@ -3626,6 +4228,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3626
4228
  run,
3627
4229
  abort,
3628
4230
  cancelTool,
4231
+ killBackgroundTask,
3629
4232
  steer,
3630
4233
  followUp: followUpFn,
3631
4234
  waitForIdle,
@@ -4758,254 +5361,39 @@ function normalizeInteger(value, fallback) {
4758
5361
  return Math.floor(value);
4759
5362
  }
4760
5363
  //#endregion
4761
- //#region src/tools/shell-semantics.ts
4762
- const DEFAULT_SEMANTIC = (exitCode) => ({
4763
- isError: exitCode !== 0,
4764
- message: exitCode !== 0 ? `Command failed with exit code ${exitCode}` : void 0
4765
- });
4766
- const COMMAND_SEMANTICS = new Map([
4767
- ["grep", (exit) => ({
4768
- isError: exit >= 2,
4769
- message: exit === 1 ? "No matches found" : void 0
4770
- })],
4771
- ["rg", (exit) => ({
4772
- isError: exit >= 2,
4773
- message: exit === 1 ? "No matches found" : void 0
4774
- })],
4775
- ["diff", (exit) => ({
4776
- isError: exit >= 2,
4777
- message: exit === 1 ? "Files differ" : void 0
4778
- })],
4779
- ["find", (exit) => ({
4780
- isError: exit >= 2,
4781
- message: exit === 1 ? "Some directories were inaccessible" : void 0
4782
- })],
4783
- ["test", (exit) => ({
4784
- isError: exit >= 2,
4785
- message: exit === 1 ? "Condition is false" : void 0
4786
- })],
4787
- ["[", (exit) => ({
4788
- isError: exit >= 2,
4789
- message: exit === 1 ? "Condition is false" : void 0
4790
- })]
4791
- ]);
4792
- /**
4793
- * Pick the semantic for a command line. Best-effort: walks the command from
4794
- * right to left, taking the last segment after `|` / `&&` / `||` / `;` —
4795
- * that's the segment whose exit code propagates. Don't depend on this for
4796
- * security; it's a heuristic, not a parser.
4797
- */
4798
- function interpretShellResult(command, exitCode) {
4799
- const base = extractTrailingCommand(command);
4800
- return (COMMAND_SEMANTICS.get(base) ?? DEFAULT_SEMANTIC)(exitCode);
4801
- }
4802
- function extractTrailingCommand(command) {
4803
- const segments = command.split(/\|\||&&|[;|\n]/);
4804
- return (segments[segments.length - 1]?.trim() ?? command).split(/\s+/).filter((t) => !/^[A-Z_]\w*=/i.test(t))[0] ?? "";
4805
- }
4806
- //#endregion
4807
- //#region src/tools/shell.ts
4808
- /**
4809
- * Execute a shell command in the agent's execution context.
4810
- *
4811
- * Truncation is **tail-priority**: when stdout+stderr combined exceeds
4812
- * `maxOutputBytes`, the head is dropped and a marker `…(N bytes truncated
4813
- * from head)…` is inserted before the tail. Errors and exit summaries
4814
- * usually live at the end of output, so keeping the tail preserves the
4815
- * model's most useful signal.
4816
- *
4817
- * Defaults are tuned for typical commands (build output, test runs): the
4818
- * combined cap is 32 KiB and the per-call timeout follows the execution
4819
- * context's own default (30 s for in-process).
4820
- */
4821
- const DEFAULT_MAX_OUTPUT_BYTES = 32768;
4822
- /**
4823
- * Best-effort read-only allow-list for the leading command token. Members
4824
- * are commands whose stock behavior cannot mutate the workspace under any
4825
- * argument combination — `ls`, `cat`, `pwd`, etc. Commands that *can*
4826
- * mutate depending on flags (`find -delete`, `git tag <name>`, `tar -x`)
4827
- * are intentionally excluded; the input-aware {@link isReadOnlyShellCommand}
4828
- * predicate falls back to the conservative "not safe" answer for them, so
4829
- * the scheduler barriers them.
4830
- */
4831
- const SHELL_READ_ONLY_COMMANDS = new Set([
4832
- "ls",
4833
- "cat",
4834
- "head",
4835
- "tail",
4836
- "wc",
4837
- "pwd",
4838
- "whoami",
4839
- "id",
4840
- "date",
4841
- "uname",
4842
- "hostname",
4843
- "tty",
4844
- "echo",
4845
- "printf",
4846
- "env",
4847
- "printenv",
4848
- "which",
4849
- "type",
4850
- "command",
4851
- "file",
4852
- "stat",
4853
- "grep",
4854
- "rg",
4855
- "ag",
4856
- "true",
4857
- "false",
4858
- "test"
4859
- ]);
4860
- /**
4861
- * `git` subcommands that are pure reads regardless of arguments. Excludes
4862
- * `branch`/`tag`/`remote` (which can mutate when given a name) and
4863
- * `config` (which writes when given a value).
4864
- */
4865
- const GIT_READ_ONLY_SUBCOMMANDS = new Set([
4866
- "status",
4867
- "log",
4868
- "diff",
4869
- "show",
4870
- "blame",
4871
- "rev-parse",
4872
- "ls-files",
4873
- "ls-tree",
4874
- "cat-file",
4875
- "reflog",
4876
- "shortlog",
4877
- "describe",
4878
- "rev-list",
4879
- "name-rev",
4880
- "whatchanged",
4881
- "merge-base",
4882
- "symbolic-ref"
4883
- ]);
4884
- /**
4885
- * Conservative read-only verdict for a shell command — used to opt a
4886
- * `shell` invocation into the scheduler's concurrent fleet. Returns
4887
- * `false` (fail-closed) on anything ambiguous so the scheduler barriers
4888
- * it. Specifically:
4889
- *
4890
- * - Rejects compound commands (`;`, `&&`, `||`, `|`) and redirects (`>`,
4891
- * `>>`, `<`) — even a pipe to a read-only sink is treated as too
4892
- * complex to analyze.
4893
- * - Rejects subshell / process substitution (`$(...)`, `` `...` ``,
4894
- * `<(...)`, `>(...)`).
4895
- * - Skips leading `VAR=value` env assignments to find the real
4896
- * command token.
4897
- * - Strips a possible absolute path on the command (`/usr/bin/ls` → `ls`).
4898
- * - Allows the command iff its base name is in
4899
- * {@link SHELL_READ_ONLY_COMMANDS} OR it's `git <subcmd>` where
4900
- * `<subcmd>` is in {@link GIT_READ_ONLY_SUBCOMMANDS}.
4901
- *
4902
- * Cheap (no spawned process; regex + token scan). Safe to call from the
4903
- * hot scheduler path.
4904
- */
4905
- function isReadOnlyShellCommand(command) {
4906
- if (typeof command !== "string") return false;
4907
- const trimmed = command.trim();
4908
- if (trimmed === "") return false;
4909
- if (/[<>;&|`\n]/.test(trimmed)) return false;
4910
- if (trimmed.includes("$(") || trimmed.includes("<(") || trimmed.includes(">(")) return false;
4911
- const tokens = trimmed.split(/\s+/);
4912
- let i = 0;
4913
- while (i < tokens.length && /^[A-Z_]\w*=/i.test(tokens[i])) i++;
4914
- const head = tokens[i];
4915
- if (!head) return false;
4916
- const base = head.split("/").pop() ?? head;
4917
- if (SHELL_READ_ONLY_COMMANDS.has(base)) return true;
4918
- if (base === "git") {
4919
- const sub = tokens[i + 1];
4920
- return typeof sub === "string" && GIT_READ_ONLY_SUBCOMMANDS.has(sub);
4921
- }
4922
- return false;
4923
- }
4924
- const shell = {
4925
- isConcurrencySafe: (input) => isReadOnlyShellCommand(input.command),
5364
+ //#region src/tools/shell-kill.ts
5365
+ const shellKill = {
5366
+ isConcurrencySafe: true,
4926
5367
  spec: {
4927
- name: "shell",
4928
- description: "Execute a shell command in the project root and return its combined stdout/stderr. Output is tail-priority truncated at 32 KiB by default; errors and exit-code summaries live in the tail. By default each call appends a `(exit N, Nms)` footer and surfaces non-empty stderr in a separate section even on success — set `metadata: false` to return only stdout. Set maxOutputBytes=0 to disable truncation.",
5368
+ name: "shell_kill",
5369
+ description: [
5370
+ "Terminate a running background task started by `shell({ run_in_background: true })`.",
5371
+ "Sends SIGTERM to the whole process group so the shell wrapper AND its child commands die together.",
5372
+ "Returns the final exit info (status, exit code, output path) or a \"no such task\" message when the id is unknown / already cleaned up.",
5373
+ "Idempotent — calling on a task that has already terminated returns the cached exit info without re-killing."
5374
+ ].join("\n"),
4929
5375
  inputSchema: {
4930
5376
  type: "object",
4931
- properties: {
4932
- command: {
4933
- type: "string",
4934
- description: "Shell command to run."
4935
- },
4936
- timeout: {
4937
- type: "integer",
4938
- description: "Per-call timeout in milliseconds."
4939
- },
4940
- maxOutputBytes: {
4941
- type: "integer",
4942
- description: "Truncate combined stdout+stderr beyond this many bytes. Default: 32768. Set 0 for unlimited."
4943
- },
4944
- metadata: {
4945
- type: "boolean",
4946
- description: "Append `(exit N, Nms)` footer and surface non-empty stderr on success. Default: true."
4947
- }
4948
- },
4949
- required: ["command"]
5377
+ properties: { task_id: {
5378
+ type: "string",
5379
+ description: "The task id returned by a prior `shell({ run_in_background: true })` call."
5380
+ } },
5381
+ required: ["task_id"],
5382
+ additionalProperties: false
4950
5383
  }
4951
5384
  },
4952
- async execute({ command, timeout, maxOutputBytes, metadata }, ctx) {
4953
- const execOpts = {};
4954
- if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) execOpts.timeout = Math.max(1, Math.ceil(timeout / 1e3));
4955
- const cmd = command;
4956
- const wantMetadata = metadata !== false;
4957
- const startedAt = Date.now();
4958
- const result = await ctx.execution.exec(ctx.handle, cmd, execOpts);
4959
- const durationMs = Date.now() - startedAt;
4960
- const cap = normalizeCap(maxOutputBytes);
4961
- const semantic = interpretShellResult(cmd, result.exitCode);
4962
- if (result.exitCode === 0) {
4963
- const stdoutTail = truncateTail(result.stdout || "(no output)", cap);
4964
- if (!wantMetadata) return stdoutTail;
4965
- const stderrTrimmed = result.stderr.trim();
4966
- return `${stdoutTail}${stderrTrimmed ? `\n[stderr]\n${truncateTail(stderrTrimmed, Math.min(cap, 2048))}` : ""}\n(exit 0, ${durationMs}ms)`;
4967
- }
4968
- if (!semantic.isError) {
4969
- const tail = truncateTail((result.stdout || result.stderr || "").trim(), cap);
4970
- const semanticFooter = semantic.message ? `\n(${semantic.message})` : "";
4971
- const timingFooter = wantMetadata ? `\n(exit ${result.exitCode}, ${durationMs}ms)` : "";
4972
- return `${tail.length > 0 ? tail : semantic.message ?? "(no output)"}${semanticFooter}${timingFooter}`;
4973
- }
4974
- const combined = `${result.stdout}\n${result.stderr}`.trim();
4975
- return `${wantMetadata ? `Exit code ${result.exitCode} (${durationMs}ms)` : `Exit code ${result.exitCode}`}\n${truncateTail(combined, cap)}`;
5385
+ async execute(input, ctx) {
5386
+ const taskId = input.task_id;
5387
+ if (!ctx.execution.killBackground) return `shell_kill error: the active execution context (${ctx.execution.type}) does not support background tasks.`;
5388
+ const info = await ctx.execution.killBackground(ctx.handle, taskId);
5389
+ if (!info) return `shell_kill: no such task "${taskId}". It may have already exited and been cleaned up, or it was never started in this session.`;
5390
+ return [
5391
+ `Killed ${info.taskId} ${formatTaskStatus(info)} after ${formatDuration(info.durationMs)}.`,
5392
+ ` command: ${previewLine(info.command, 60)}`,
5393
+ ` output: ${info.outputPath}`
5394
+ ].join("\n");
4976
5395
  }
4977
5396
  };
4978
- function normalizeCap(value) {
4979
- if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_MAX_OUTPUT_BYTES;
4980
- if (value < 0) return DEFAULT_MAX_OUTPUT_BYTES;
4981
- return Math.floor(value);
4982
- }
4983
- /**
4984
- * Tail-priority byte truncation. When `text` exceeds `cap` bytes, the head is
4985
- * dropped and replaced with a marker. Always cuts on character boundaries (no
4986
- * mid-codepoint splits) by walking from the end with `Buffer.byteLength`.
4987
- *
4988
- * `cap === 0` disables truncation. `cap` is interpreted as a UTF-8 byte budget
4989
- * for the tail itself — the marker is added on top and may push the visible
4990
- * length slightly past `cap`. That tradeoff is intentional: a marker that
4991
- * always fits inside the budget would shrink the actual content displayed.
4992
- */
4993
- function truncateTail(text, cap) {
4994
- if (cap === 0) return text;
4995
- const totalBytes = Buffer.byteLength(text);
4996
- if (totalBytes <= cap) return text;
4997
- let bytes = 0;
4998
- let charIdx = text.length;
4999
- while (charIdx > 0) {
5000
- const ch = text[charIdx - 1];
5001
- const chBytes = Buffer.byteLength(ch);
5002
- if (bytes + chBytes > cap) break;
5003
- bytes += chBytes;
5004
- charIdx--;
5005
- }
5006
- const tail = text.slice(charIdx);
5007
- return `…(${totalBytes - Buffer.byteLength(tail)} bytes truncated from head)…\n${tail}`;
5008
- }
5009
5397
  //#endregion
5010
5398
  //#region src/tools/spawn.ts
5011
5399
  const BUBBLED_EVENTS = [
@@ -5018,6 +5406,9 @@ const BUBBLED_EVENTS = [
5018
5406
  "tool:after",
5019
5407
  "tool:error",
5020
5408
  "tool:cancelled",
5409
+ "background:start",
5410
+ "background:exit",
5411
+ "background:reassign",
5021
5412
  "turn:after"
5022
5413
  ];
5023
5414
  const BUBBLED_MUTABLE_EVENTS = [
@@ -5035,6 +5426,9 @@ const CHILD_EVENT_NAME = {
5035
5426
  "tool:after": "child:tool:after",
5036
5427
  "tool:error": "child:tool:error",
5037
5428
  "tool:cancelled": "child:tool:cancelled",
5429
+ "background:start": "child:background:start",
5430
+ "background:exit": "child:background:exit",
5431
+ "background:reassign": "child:background:reassign",
5038
5432
  "turn:after": "child:turn:after"
5039
5433
  };
5040
5434
  const CHILD_MUTABLE_EVENT_NAME = {
@@ -5349,6 +5743,24 @@ function createSpawnTool(options = {}) {
5349
5743
  });
5350
5744
  }
5351
5745
  } finally {
5746
+ const childHandle = agent.handle;
5747
+ if (childHandle && ctx.execution.reassignBackgroundTasks) try {
5748
+ const reassigned = await ctx.execution.reassignBackgroundTasks(childHandle, ctx.handle, (info) => {
5749
+ ctx.hooks.callHook("background:exit", info);
5750
+ });
5751
+ for (const entry of reassigned) await ctx.hooks.callHook("background:reassign", {
5752
+ taskId: entry.taskId,
5753
+ fromHandleId: childHandle.id,
5754
+ toHandleId: ctx.handle.id,
5755
+ childId: id,
5756
+ pid: entry.pid,
5757
+ command: entry.command,
5758
+ outputPath: entry.outputPath,
5759
+ startedAt: entry.startedAt
5760
+ });
5761
+ } catch (err) {
5762
+ if (process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/spawn] reassignBackgroundTasks failed: ${err instanceof Error ? err.message : String(err)}\n`);
5763
+ }
5352
5764
  try {
5353
5765
  await agent.destroy();
5354
5766
  } catch (err) {
@@ -5438,6 +5850,6 @@ const writeFile$1 = {
5438
5850
  }
5439
5851
  };
5440
5852
  //#endregion
5441
- export { hashContent as A, PERSISTED_STUB_PREFIX as C, maybePersistToolResult as D, cleanupPersistedSession as E, resolveReadStateMap as M, resolvePersistDir as O, validateToolArgs as S, buildPersistedStub as T, createSkillsReadTool as _, multiEdit as a, TOOL_USE_CANCELLED_MESSAGE as b, grep as c, resolveOldString as d, styleReplacementForVia as f, createSkillsRunScriptTool as g, createSkillsUseTool as h, readFile$1 as i, readStateKey as j, getReadState as k, glob as l, createToolSearchTool as m, createSpawnTool as n, listFiles as o, createAgent as p, shell as r, createInteractionTool as s, writeFile$1 as t, edit as u, INTERRUPT_MESSAGE_FOR_TOOL_USE as v, PERSISTENCE_PREVIEW_BYTES as w, TOOL_USE_SKIPPED_MESSAGE as x, SHELL_CASCADE_CANCEL_MESSAGE as y };
5853
+ export { resolvePersistDir as A, formatTaskStatus as B, TOOL_USE_SKIPPED_MESSAGE as C, buildPersistedStub as D, PERSISTENCE_PREVIEW_BYTES as E, resolveReadStateMap as F, previewLine as H, ageString as I, compactPath as L, getReadState as M, hashContent as N, cleanupPersistedSession as O, readStateKey as P, fmtTokens as R, TOOL_USE_CANCELLED_MESSAGE as S, PERSISTED_STUB_PREFIX as T, shortId as U, formatTaskSummary as V, createSkillsReadTool as _, multiEdit as a, INTERRUPT_MESSAGE_FOR_TOOL_USE as b, grep as c, resolveOldString as d, styleReplacementForVia as f, createSkillsRunScriptTool as g, createSkillsUseTool as h, readFile$1 as i, resolveTasksDir as j, maybePersistToolResult as k, glob as l, createToolSearchTool as m, createSpawnTool as n, listFiles as o, createAgent as p, shellKill as r, createInteractionTool as s, writeFile$1 as t, edit as u, createShellTool as v, validateToolArgs as w, SHELL_CASCADE_CANCEL_MESSAGE as x, shell as y, formatDuration as z };
5442
5854
 
5443
- //# sourceMappingURL=tools-BK2vG9UX.js.map
5855
+ //# sourceMappingURL=tools-D_icxa-V.js.map