zidane 5.4.2 → 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 (86) hide show
  1. package/README.md +45 -1
  2. package/dist/{agent-DxBoKDba.d.ts → agent-CvImMxMQ.d.ts} +256 -5
  3. package/dist/agent-CvImMxMQ.d.ts.map +1 -0
  4. package/dist/chat.d.ts +137 -16
  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/{errors-Byb0F8B9.js → errors-CDwtPIMX.js} +4 -2
  13. package/dist/{errors-Byb0F8B9.js.map → errors-CDwtPIMX.js.map} +1 -1
  14. package/dist/{index-BOtXdQkW.d.ts → index-B0uc2C5x.d.ts} +9 -3
  15. package/dist/index-B0uc2C5x.d.ts.map +1 -0
  16. package/dist/{index-BiO_5Hm4.d.ts → index-CbS75MD3.d.ts} +2 -2
  17. package/dist/index-CbS75MD3.d.ts.map +1 -0
  18. package/dist/{index-B2VOOijU.d.ts → index-CtXksgqb.d.ts} +73 -4
  19. package/dist/index-CtXksgqb.d.ts.map +1 -0
  20. package/dist/index.d.ts +6 -6
  21. package/dist/index.js +11 -11
  22. package/dist/{interpolate-ERgZUxgg.js → interpolate-BaaKaKzN.js} +156 -19
  23. package/dist/interpolate-BaaKaKzN.js.map +1 -0
  24. package/dist/{login-CJbeAadS.js → login-iTy-0wYz.js} +3 -3
  25. package/dist/{login-CJbeAadS.js.map → login-iTy-0wYz.js.map} +1 -1
  26. package/dist/{mcp-DhmmJfxK.js → mcp-CNUbvbsy.js} +2 -2
  27. package/dist/{mcp-DhmmJfxK.js.map → mcp-CNUbvbsy.js.map} +1 -1
  28. package/dist/mcp.d.ts +1 -1
  29. package/dist/mcp.js +1 -1
  30. package/dist/{messages-D0xT979U.js → messages-fTR19Ga6.js} +2 -2
  31. package/dist/{messages-D0xT979U.js.map → messages-fTR19Ga6.js.map} +1 -1
  32. package/dist/{presets-MCcvxiNT.js → presets-h6UWhghO.js} +3 -2
  33. package/dist/presets-h6UWhghO.js.map +1 -0
  34. package/dist/presets.d.ts +2 -2
  35. package/dist/presets.js +1 -1
  36. package/dist/{providers-x3LZByR5.js → providers-G0VBZK9j.js} +4 -4
  37. package/dist/{providers-x3LZByR5.js.map → providers-G0VBZK9j.js.map} +1 -1
  38. package/dist/providers.d.ts +1 -1
  39. package/dist/providers.js +2 -2
  40. package/dist/session/sqlite.d.ts +1 -1
  41. package/dist/session/sqlite.d.ts.map +1 -1
  42. package/dist/session/sqlite.js +2 -1
  43. package/dist/session/sqlite.js.map +1 -1
  44. package/dist/{session-BHZwxmfr.js → session-CbkiJDlH.js} +3 -2
  45. package/dist/session-CbkiJDlH.js.map +1 -0
  46. package/dist/session.d.ts +1 -1
  47. package/dist/session.js +2 -2
  48. package/dist/skills.d.ts +2 -2
  49. package/dist/skills.js +1 -1
  50. package/dist/{tools-BNfyY14s.js → tools-D_icxa-V.js} +813 -284
  51. package/dist/tools-D_icxa-V.js.map +1 -0
  52. package/dist/tools.d.ts +3 -3
  53. package/dist/tools.js +2 -2
  54. package/dist/{transcript-anchors-DonKvoh4.d.ts → transcript-anchors-3FFw2xuk.d.ts} +98 -15
  55. package/dist/transcript-anchors-3FFw2xuk.d.ts.map +1 -0
  56. package/dist/tui.d.ts +29 -5
  57. package/dist/tui.d.ts.map +1 -1
  58. package/dist/tui.js +879 -70
  59. package/dist/tui.js.map +1 -1
  60. package/dist/{turn-operations-TKvy0q29.js → turn-operations-CtgBlBHn.js} +412 -125
  61. package/dist/turn-operations-CtgBlBHn.js.map +1 -0
  62. package/dist/types-IcokUOyC.js.map +1 -1
  63. package/dist/types-KukEp-mi.d.ts +253 -0
  64. package/dist/types-KukEp-mi.d.ts.map +1 -0
  65. package/dist/types.d.ts +4 -4
  66. package/dist/types.js +1 -1
  67. package/docs/ARCHITECTURE.md +37 -3
  68. package/docs/CHAT.md +4 -2
  69. package/docs/RUN_IN_BACKGROUND.md +612 -0
  70. package/docs/SKILL.md +83 -14
  71. package/docs/TUI.md +40 -2
  72. package/package.json +4 -4
  73. package/dist/agent-DxBoKDba.d.ts.map +0 -1
  74. package/dist/contexts-BwiHIr2w.js +0 -129
  75. package/dist/contexts-BwiHIr2w.js.map +0 -1
  76. package/dist/index-B2VOOijU.d.ts.map +0 -1
  77. package/dist/index-BOtXdQkW.d.ts.map +0 -1
  78. package/dist/index-BiO_5Hm4.d.ts.map +0 -1
  79. package/dist/interpolate-ERgZUxgg.js.map +0 -1
  80. package/dist/presets-MCcvxiNT.js.map +0 -1
  81. package/dist/session-BHZwxmfr.js.map +0 -1
  82. package/dist/tools-BNfyY14s.js.map +0 -1
  83. package/dist/transcript-anchors-DonKvoh4.d.ts.map +0 -1
  84. package/dist/turn-operations-TKvy0q29.js.map +0 -1
  85. package/dist/types-Ce78ds4h.d.ts +0 -88
  86. package/dist/types-Ce78ds4h.d.ts.map +0 -1
@@ -1,13 +1,14 @@
1
- import { n as createProcessContext } from "./contexts-BwiHIr2w.js";
2
- import { a as AgentToolPairingError, l as toTypedError, r as AgentProviderError, s as errorMessage, t as AgentAbortedError } from "./errors-Byb0F8B9.js";
1
+ import { n as createProcessContext } from "./contexts-DhmMlT2W.js";
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
- import { a as detectTurnInterruption, n as SYNTHETIC_TOOL_RESULT_PLACEHOLDER, o as ensureToolResultPairing, s as filterUnresolvedToolUses } from "./messages-D0xT979U.js";
5
- import { t as connectMcpServers } from "./mcp-DhmmJfxK.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-ERgZUxgg.js";
4
+ import { a as detectTurnInterruption, n as SYNTHETIC_TOOL_RESULT_PLACEHOLDER, o as ensureToolResultPairing, s as filterUnresolvedToolUses } from "./messages-fTR19Ga6.js";
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-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.
@@ -812,6 +949,27 @@ const INTERRUPT_MESSAGE_FOR_TOOL_USE = "[Request interrupted by user for tool us
812
949
  */
813
950
  const TOOL_USE_SKIPPED_MESSAGE = "[Tool use skipped — superseded by user message]";
814
951
  /**
952
+ * Canonical tool_result text emitted when a single tool call is cancelled
953
+ * mid-flight via `agent.cancelTool(callId)` (typically the TUI's
954
+ * "cancel this tool" affordance). Distinguished from
955
+ * {@link INTERRUPT_MESSAGE_FOR_TOOL_USE} (run-wide user abort) and
956
+ * {@link TOOL_USE_SKIPPED_MESSAGE} (steered) so the model — and downstream
957
+ * consumers — can tell the three apart by string match.
958
+ *
959
+ * Always paired with `isError: true` on the wire so the model treats the
960
+ * call as failed rather than as a successful response. The remaining tool
961
+ * calls in the batch continue running, in contrast with a full-run abort.
962
+ */
963
+ const TOOL_USE_CANCELLED_MESSAGE = "[Tool call cancelled by user]";
964
+ /**
965
+ * Sentinel message rejected from the per-call cancellation promise inside
966
+ * {@link executeSingleTool}'s race. Plain-string and module-private — only
967
+ * the surrounding code reads it (via the `perCallAbort.signal.aborted`
968
+ * guard, NOT the message itself), so it just needs to be a stable
969
+ * non-empty identifier the rejection can carry.
970
+ */
971
+ const CANCELLED_BY_USER_SENTINEL = "zidane:tool:cancelled-by-user";
972
+ /**
815
973
  * Compute the effective thinking budget for a given run-relative turn, given
816
974
  * the configured decay schedule. Pure helper — exported for tests and so
817
975
  * downstream tooling can preview decay curves without spinning up the loop.
@@ -1670,6 +1828,29 @@ async function executeSingleTool(ctx, call, turnId) {
1670
1828
  const callId = call.id;
1671
1829
  const displayName = toWireName(call.name, ctx.aliasMaps);
1672
1830
  const runToolCounts = Object.freeze({ ...ctx.runToolCounts });
1831
+ const perCallAbort = new AbortController();
1832
+ ctx.pendingToolCancels?.set(callId, perCallAbort);
1833
+ try {
1834
+ return await runSingleToolDispatch(ctx, call, turnId, {
1835
+ toolDef,
1836
+ callId,
1837
+ displayName,
1838
+ runToolCounts,
1839
+ perCallAbort
1840
+ });
1841
+ } finally {
1842
+ ctx.pendingToolCancels?.delete(callId);
1843
+ }
1844
+ }
1845
+ /**
1846
+ * Body of {@link executeSingleTool}. Hoisted into its own function purely so
1847
+ * the per-call cancel registration (which spans every exit path) can sit in
1848
+ * a tight `try / finally` at the call site. Behavior is unchanged from the
1849
+ * pre-cancel implementation aside from the user-cancel branch in the
1850
+ * execute body — see {@link TOOL_USE_CANCELLED_MESSAGE}.
1851
+ */
1852
+ async function runSingleToolDispatch(ctx, call, turnId, fixed) {
1853
+ const { toolDef, callId, displayName, runToolCounts, perCallAbort } = fixed;
1673
1854
  const gateCtx = {
1674
1855
  ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, call.input),
1675
1856
  block: false,
@@ -1795,12 +1976,14 @@ async function executeSingleTool(ctx, call, turnId) {
1795
1976
  runToolCounts,
1796
1977
  ...coercions ? { coercions } : {}
1797
1978
  });
1798
- let output;
1979
+ let output = "";
1799
1980
  let isError = false;
1981
+ let cancelledByUser = false;
1982
+ const childSignal = typeof AbortSignal.any === "function" ? AbortSignal.any([ctx.signal, perCallAbort.signal]) : ctx.signal;
1800
1983
  try {
1801
1984
  const toolCtx = {
1802
1985
  provider: ctx.provider,
1803
- signal: ctx.signal,
1986
+ signal: childSignal,
1804
1987
  execution: ctx.execution,
1805
1988
  handle: ctx.handle,
1806
1989
  hooks: ctx.hooks,
@@ -1819,16 +2002,50 @@ async function executeSingleTool(ctx, call, turnId) {
1819
2002
  ...ctx.readState ? { readState: ctx.readState } : {},
1820
2003
  ...typeof ctx.depth === "number" ? { depth: ctx.depth } : {}
1821
2004
  };
1822
- output = await toolDef.execute(effectiveInput, toolCtx);
2005
+ const bodyPromise = toolDef.execute(effectiveInput, toolCtx);
2006
+ bodyPromise.catch(() => {});
2007
+ let removeAbortListener;
2008
+ const cancellationPromise = new Promise((_, reject) => {
2009
+ if (perCallAbort.signal.aborted) {
2010
+ reject(new Error(CANCELLED_BY_USER_SENTINEL));
2011
+ return;
2012
+ }
2013
+ const onAbort = () => reject(new Error(CANCELLED_BY_USER_SENTINEL));
2014
+ perCallAbort.signal.addEventListener("abort", onAbort, { once: true });
2015
+ removeAbortListener = () => perCallAbort.signal.removeEventListener("abort", onAbort);
2016
+ });
2017
+ try {
2018
+ output = await Promise.race([bodyPromise, cancellationPromise]);
2019
+ } finally {
2020
+ removeAbortListener?.();
2021
+ }
1823
2022
  } catch (err) {
1824
- const error = err instanceof Error ? err : new Error(String(err));
1825
- const errorCtx = {
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;
2026
+ else {
2027
+ const error = err instanceof Error ? err : new Error(String(err));
2028
+ const errorCtx = {
2029
+ ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, effectiveInput),
2030
+ error
2031
+ };
2032
+ await ctx.hooks.callHook("tool:error", errorCtx);
2033
+ output = errorCtx.result ?? `Tool error: ${error.message}`;
2034
+ isError = true;
2035
+ }
2036
+ }
2037
+ if (cancelledByUser) {
2038
+ const reason = typeof perCallAbort.signal.reason === "string" ? perCallAbort.signal.reason : "cancelled-by-user";
2039
+ await ctx.hooks.callHook("tool:cancelled", {
1826
2040
  ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, effectiveInput),
1827
- error
1828
- };
1829
- await ctx.hooks.callHook("tool:error", errorCtx);
1830
- output = errorCtx.result ?? `Tool error: ${error.message}`;
1831
- isError = true;
2041
+ reason,
2042
+ runToolCounts
2043
+ });
2044
+ return { result: {
2045
+ id: callId,
2046
+ content: TOOL_USE_CANCELLED_MESSAGE,
2047
+ isError: true
2048
+ } };
1832
2049
  }
1833
2050
  const emitted = await emitToolResult(ctx, {
1834
2051
  turnId,
@@ -2032,7 +2249,8 @@ async function executeToolBatch(ctx, toolCalls, turnId) {
2032
2249
  const call = toolCalls[index];
2033
2250
  return executeSingleTool(childCtx, call, turnId).then(({ result }) => {
2034
2251
  results[index] = result;
2035
- 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);
2036
2254
  }, (err) => {
2037
2255
  const isAbort = siblingAbort.signal.aborted || ctx.signal.aborted || err instanceof Error && err.name === "AbortError";
2038
2256
  results[index] = {
@@ -2259,6 +2477,376 @@ function defaultBlockMessage(tool, max) {
2259
2477
  return `Tool '${tool}' has reached its per-run budget of ${max} calls; further invocations are refused.`;
2260
2478
  }
2261
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
2262
2850
  //#region src/tools/binary-detect.ts
2263
2851
  /**
2264
2852
  * Heuristics for detecting binary content in UTF-8-decoded strings.
@@ -2426,7 +3014,10 @@ function createSkillsRunScriptTool(options) {
2426
3014
  if (!validated.valid) return `Error: ${validated.error}`;
2427
3015
  const cmd = [validated.absolutePath, ...args].map(alwaysQuote).join(" ");
2428
3016
  try {
2429
- 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
+ });
2430
3021
  return JSON.stringify({
2431
3022
  exitCode: result.exitCode,
2432
3023
  stdout: result.stdout,
@@ -2480,14 +3071,21 @@ function createSkillsUseTool(options) {
2480
3071
  return {
2481
3072
  spec: {
2482
3073
  name: "skills_use",
2483
- description: "Activate a specialized skill and load its full instructions. Call this when a task matches a skill's description from the catalog. After calling, follow the returned instructions; use skills_read to load referenced files and skills_run_script to execute bundled scripts.",
3074
+ description: "Activate or deactivate a specialized skill. Call with `mode: \"activate\"` (default) to load a skill's full instructions when a task matches its catalog description. Call with `mode: \"deactivate\"` to release a skill whose allowed-tools restrictions are now in the way — e.g. when the skill's work is done or the active skill is blocking unrelated tool calls. After activating, follow the returned instructions; use skills_read to load referenced files and skills_run_script to execute bundled scripts.",
2484
3075
  inputSchema: {
2485
3076
  type: "object",
2486
- properties: { name: {
2487
- type: "string",
2488
- enum: options.catalog.map((s) => s.name),
2489
- description: "The name of the skill to activate (must be in the available skills catalog)."
2490
- } },
3077
+ properties: {
3078
+ name: {
3079
+ type: "string",
3080
+ enum: options.catalog.map((s) => s.name),
3081
+ description: "The name of the skill to activate or deactivate (must be in the available skills catalog)."
3082
+ },
3083
+ mode: {
3084
+ type: "string",
3085
+ enum: ["activate", "deactivate"],
3086
+ description: "Whether to activate (load + apply the skill) or deactivate (release an active skill). Default: \"activate\"."
3087
+ }
3088
+ },
2491
3089
  required: ["name"],
2492
3090
  additionalProperties: false
2493
3091
  }
@@ -2496,8 +3094,18 @@ function createSkillsUseTool(options) {
2496
3094
  const skillName = input.name;
2497
3095
  const skill = byName.get(skillName);
2498
3096
  if (!skill) return `Error: unknown skill "${skillName}". Available skills: ${[...byName.keys()].join(", ") || "<none>"}.`;
3097
+ if ((input.mode ?? "activate") === "deactivate") {
3098
+ const removed = options.state.deactivate(skillName);
3099
+ if (!removed) return `Skill "${skillName}" was not active — nothing to deactivate.`;
3100
+ await options.hooks.callHook("skills:deactivate", {
3101
+ skill: removed.skill,
3102
+ reason: "model"
3103
+ });
3104
+ const remaining = options.state.active().map((a) => a.skill.name);
3105
+ return `Skill "${skillName}" deactivated — its allowed-tools restrictions no longer apply.${remaining.length > 0 ? ` Remaining active skills: ${remaining.join(", ")}.` : " No skills are currently active."}`;
3106
+ }
2499
3107
  if (!options.state.isActive(skillName)) {
2500
- if (options.state.activate(skill, "model") === "cap-reached") return `Error: cannot activate "${skillName}" — the maxActive skill cap has been reached. Currently active: ${options.state.active().map((a) => a.skill.name).join(", ")}. Deactivate an existing skill first.`;
3108
+ if (options.state.activate(skill, "model") === "cap-reached") return `Error: cannot activate "${skillName}" — the maxActive skill cap has been reached. Currently active: ${options.state.active().map((a) => a.skill.name).join(", ")}. Call \`skills_use\` with \`mode: "deactivate"\` and one of those names first.`;
2501
3109
  await options.hooks.callHook("skills:activate", {
2502
3110
  skill,
2503
3111
  via: "model"
@@ -2656,6 +3264,39 @@ function createToolSearchTool(options) {
2656
3264
  }
2657
3265
  //#endregion
2658
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
+ }
2659
3300
  const HOOK_EVENT_SET = new Set([
2660
3301
  "system:before",
2661
3302
  "agent:start",
@@ -2673,6 +3314,7 @@ const HOOK_EVENT_SET = new Set([
2673
3314
  "tool:before",
2674
3315
  "tool:after",
2675
3316
  "tool:error",
3317
+ "tool:cancelled",
2676
3318
  "tool:transform",
2677
3319
  "tool:unknown",
2678
3320
  "validation:reject",
@@ -2694,6 +3336,10 @@ const HOOK_EVENT_SET = new Set([
2694
3336
  "child:tool:after",
2695
3337
  "child:tool:transform",
2696
3338
  "child:tool:error",
3339
+ "child:tool:cancelled",
3340
+ "child:background:start",
3341
+ "child:background:exit",
3342
+ "child:background:reassign",
2697
3343
  "child:turn:after",
2698
3344
  "mcp:connect",
2699
3345
  "mcp:error",
@@ -2710,6 +3356,9 @@ const HOOK_EVENT_SET = new Set([
2710
3356
  "mcp:tool:after",
2711
3357
  "mcp:tool:transform",
2712
3358
  "mcp:tool:error",
3359
+ "background:start",
3360
+ "background:exit",
3361
+ "background:reassign",
2713
3362
  "skills:resolve",
2714
3363
  "skills:catalog",
2715
3364
  "skills:activate",
@@ -2810,6 +3459,8 @@ function resolveBehavior(agentBehavior, runBehavior) {
2810
3459
  persistThreshold: runBehavior?.persistThreshold ?? agentBehavior?.persistThreshold,
2811
3460
  persistExcludeTools: runBehavior?.persistExcludeTools ?? agentBehavior?.persistExcludeTools,
2812
3461
  persistDir: runBehavior?.persistDir ?? agentBehavior?.persistDir,
3462
+ tasksDir: runBehavior?.tasksDir ?? agentBehavior?.tasksDir,
3463
+ disableBackgroundTasks: runBehavior?.disableBackgroundTasks ?? agentBehavior?.disableBackgroundTasks,
2813
3464
  strictToolPairing: runBehavior?.strictToolPairing ?? agentBehavior?.strictToolPairing ?? false
2814
3465
  };
2815
3466
  }
@@ -2973,6 +3624,8 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
2973
3624
  let running = false;
2974
3625
  let idleResolve;
2975
3626
  let idlePromise;
3627
+ const pendingTaskNotifications = /* @__PURE__ */ new Map();
3628
+ const pendingToolCancels = /* @__PURE__ */ new Map();
2976
3629
  let executionHandle = null;
2977
3630
  let mcpConnection = null;
2978
3631
  let mcpWarmupPromise = null;
@@ -3040,6 +3693,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3040
3693
  running = true;
3041
3694
  try {
3042
3695
  abortController = new AbortController();
3696
+ runCounter = Math.max(runCounter, initialRunCounter(session));
3043
3697
  const runId = `run_${++runCounter}`;
3044
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") : "";
3045
3699
  session?.startRun(runId, promptLabel, {
@@ -3090,20 +3744,27 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3090
3744
  await ensureSkillsResolved();
3091
3745
  if (resolvedSkills && session && session.turns.length > 0 && skillActivationState.active().length === 0) {
3092
3746
  const skillsByName = new Map(resolvedSkills.map((s) => [s.name, s]));
3747
+ const lastModeBySkill = /* @__PURE__ */ new Map();
3093
3748
  for (const turn of session.turns) {
3094
3749
  if (turn.role !== "assistant") continue;
3095
3750
  for (const block of turn.content) {
3096
3751
  if (block.type !== "tool_call" || block.name !== "skills_use") continue;
3097
- const skillName = block.input?.name;
3752
+ const input = block.input;
3753
+ const skillName = input?.name;
3098
3754
  if (!skillName) continue;
3099
- const skill = skillsByName.get(skillName);
3100
- if (!skill) continue;
3101
- if (skillActivationState.activate(skill, "resume") === "ok") await hooks.callHook("skills:activate", {
3102
- skill,
3103
- via: "resume"
3104
- });
3755
+ const mode = input?.mode === "deactivate" ? "deactivate" : "activate";
3756
+ lastModeBySkill.set(skillName, mode);
3105
3757
  }
3106
3758
  }
3759
+ for (const [skillName, mode] of lastModeBySkill) {
3760
+ if (mode !== "activate") continue;
3761
+ const skill = skillsByName.get(skillName);
3762
+ if (!skill) continue;
3763
+ if (skillActivationState.activate(skill, "resume") === "ok") await hooks.callHook("skills:activate", {
3764
+ skill,
3765
+ via: "resume"
3766
+ });
3767
+ }
3107
3768
  }
3108
3769
  const thinking = options.thinking ?? "off";
3109
3770
  const model = options.model ?? provider.meta.defaultModel;
@@ -3135,6 +3796,9 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3135
3796
  } : runBaseTools;
3136
3797
  const toolsPreSearch = {};
3137
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
+ }
3138
3802
  const disclosure = partitionToolDisclosure(toolsPreSearch, mcpToolNames, mcpServers, toolDisclosure, toolAliases);
3139
3803
  const unlocked = new Set(disclosure.eagerCanonicalNames);
3140
3804
  const hostDefinedToolSearch = !!toolsPreSearch.tool_search;
@@ -3169,8 +3833,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3169
3833
  }
3170
3834
  const formattedTools = buildFormattedTools();
3171
3835
  const turns = [];
3172
- const isResume = session && session.turns.length > 0 && (session.runs.length > 0 || !options.prompt) && !options.parentRunId;
3173
- if (isResume) {
3836
+ if (session && session.turns.length > 0 && (session.runs.length > 0 || !options.prompt) && !options.parentRunId) {
3174
3837
  const childRunIds = new Set(session.runs.filter((r) => (r.depth ?? 0) > 0).map((r) => r.id));
3175
3838
  const resumed = childRunIds.size === 0 ? session.turns : session.turns.filter((t) => !t.runId || !childRunIds.has(t.runId));
3176
3839
  const filteredForRuntime = resumeFilteredTurns && resumed === session.turns ? resumeFilteredTurns : filterUnresolvedToolUses(resumed);
@@ -3178,19 +3841,28 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3178
3841
  }
3179
3842
  const runTurnStart = turns.length;
3180
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;
3181
3853
  const promptParts = canonicalizePrompt(options.prompt);
3182
- if (promptParts) {
3183
- 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 : []];
3184
3857
  turns.push({
3185
3858
  id: crypto.randomUUID(),
3186
3859
  runId,
3187
- role: promptMsg.role,
3188
- content: promptMsg.content,
3860
+ role: promptMsg ? promptMsg.role : "user",
3861
+ content,
3189
3862
  createdAt: Date.now()
3190
3863
  });
3191
3864
  }
3192
3865
  conversationTurns = turns;
3193
- let lastPersistedTurnCount = isResume ? session.turns.length : 0;
3194
3866
  if (session && turns.length > lastPersistedTurnCount) {
3195
3867
  const seededTurns = turns.slice(lastPersistedTurnCount);
3196
3868
  await session.appendTurns(seededTurns);
@@ -3301,7 +3973,8 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3301
3973
  ...strictToolPairing ? { strictToolPairing: true } : {},
3302
3974
  providerName: provider.name,
3303
3975
  runStartMs,
3304
- runToolCounts: {}
3976
+ runToolCounts: {},
3977
+ pendingToolCancels
3305
3978
  });
3306
3979
  const parentTurnCost = stats.turnUsage?.reduce((sum, t) => sum + (t.cost ?? 0), 0) ?? 0;
3307
3980
  let childrenIn = 0;
@@ -3398,6 +4071,17 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3398
4071
  function abort() {
3399
4072
  abortController?.abort();
3400
4073
  }
4074
+ function cancelTool(callId, reason) {
4075
+ const controller = pendingToolCancels.get(callId);
4076
+ if (!controller || controller.signal.aborted) return false;
4077
+ controller.abort(reason ?? "user-cancelled-tool");
4078
+ return true;
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
+ }
3401
4085
  function steer(message) {
3402
4086
  steeringQueue.push(message);
3403
4087
  }
@@ -3412,6 +4096,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3412
4096
  conversationTurns = [];
3413
4097
  steeringQueue.length = 0;
3414
4098
  followUpQueue.length = 0;
4099
+ pendingTaskNotifications.clear();
3415
4100
  const cleared = skillActivationState.clear();
3416
4101
  for (const record of cleared) await hooks.callHook("skills:deactivate", {
3417
4102
  skill: record.skill,
@@ -3440,6 +4125,33 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3440
4125
  reason: "explicit"
3441
4126
  });
3442
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
+ });
3443
4155
  if (session) {
3444
4156
  const originalSave = session.save.bind(session);
3445
4157
  const originalSetMeta = session.setMeta.bind(session);
@@ -3491,6 +4203,9 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3491
4203
  async function destroy() {
3492
4204
  if (destroyed) return;
3493
4205
  destroyed = true;
4206
+ pendingTaskNotifications.clear();
4207
+ for (const controller of pendingToolCancels.values()) if (!controller.signal.aborted) controller.abort("agent-destroyed");
4208
+ pendingToolCancels.clear();
3494
4209
  if (mcpWarmupPromise) try {
3495
4210
  await mcpWarmupPromise;
3496
4211
  } catch {}
@@ -3502,6 +4217,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3502
4217
  await executionContext.destroy(executionHandle);
3503
4218
  executionHandle = null;
3504
4219
  }
4220
+ pendingTaskNotifications.clear();
3505
4221
  skillsCleanup();
3506
4222
  skillsCleanup = () => {};
3507
4223
  }
@@ -3511,6 +4227,8 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3511
4227
  hooks,
3512
4228
  run,
3513
4229
  abort,
4230
+ cancelTool,
4231
+ killBackgroundTask,
3514
4232
  steer,
3515
4233
  followUp: followUpFn,
3516
4234
  waitForIdle,
@@ -4643,254 +5361,39 @@ function normalizeInteger(value, fallback) {
4643
5361
  return Math.floor(value);
4644
5362
  }
4645
5363
  //#endregion
4646
- //#region src/tools/shell-semantics.ts
4647
- const DEFAULT_SEMANTIC = (exitCode) => ({
4648
- isError: exitCode !== 0,
4649
- message: exitCode !== 0 ? `Command failed with exit code ${exitCode}` : void 0
4650
- });
4651
- const COMMAND_SEMANTICS = new Map([
4652
- ["grep", (exit) => ({
4653
- isError: exit >= 2,
4654
- message: exit === 1 ? "No matches found" : void 0
4655
- })],
4656
- ["rg", (exit) => ({
4657
- isError: exit >= 2,
4658
- message: exit === 1 ? "No matches found" : void 0
4659
- })],
4660
- ["diff", (exit) => ({
4661
- isError: exit >= 2,
4662
- message: exit === 1 ? "Files differ" : void 0
4663
- })],
4664
- ["find", (exit) => ({
4665
- isError: exit >= 2,
4666
- message: exit === 1 ? "Some directories were inaccessible" : void 0
4667
- })],
4668
- ["test", (exit) => ({
4669
- isError: exit >= 2,
4670
- message: exit === 1 ? "Condition is false" : void 0
4671
- })],
4672
- ["[", (exit) => ({
4673
- isError: exit >= 2,
4674
- message: exit === 1 ? "Condition is false" : void 0
4675
- })]
4676
- ]);
4677
- /**
4678
- * Pick the semantic for a command line. Best-effort: walks the command from
4679
- * right to left, taking the last segment after `|` / `&&` / `||` / `;` —
4680
- * that's the segment whose exit code propagates. Don't depend on this for
4681
- * security; it's a heuristic, not a parser.
4682
- */
4683
- function interpretShellResult(command, exitCode) {
4684
- const base = extractTrailingCommand(command);
4685
- return (COMMAND_SEMANTICS.get(base) ?? DEFAULT_SEMANTIC)(exitCode);
4686
- }
4687
- function extractTrailingCommand(command) {
4688
- const segments = command.split(/\|\||&&|[;|\n]/);
4689
- return (segments[segments.length - 1]?.trim() ?? command).split(/\s+/).filter((t) => !/^[A-Z_]\w*=/i.test(t))[0] ?? "";
4690
- }
4691
- //#endregion
4692
- //#region src/tools/shell.ts
4693
- /**
4694
- * Execute a shell command in the agent's execution context.
4695
- *
4696
- * Truncation is **tail-priority**: when stdout+stderr combined exceeds
4697
- * `maxOutputBytes`, the head is dropped and a marker `…(N bytes truncated
4698
- * from head)…` is inserted before the tail. Errors and exit summaries
4699
- * usually live at the end of output, so keeping the tail preserves the
4700
- * model's most useful signal.
4701
- *
4702
- * Defaults are tuned for typical commands (build output, test runs): the
4703
- * combined cap is 32 KiB and the per-call timeout follows the execution
4704
- * context's own default (30 s for in-process).
4705
- */
4706
- const DEFAULT_MAX_OUTPUT_BYTES = 32768;
4707
- /**
4708
- * Best-effort read-only allow-list for the leading command token. Members
4709
- * are commands whose stock behavior cannot mutate the workspace under any
4710
- * argument combination — `ls`, `cat`, `pwd`, etc. Commands that *can*
4711
- * mutate depending on flags (`find -delete`, `git tag <name>`, `tar -x`)
4712
- * are intentionally excluded; the input-aware {@link isReadOnlyShellCommand}
4713
- * predicate falls back to the conservative "not safe" answer for them, so
4714
- * the scheduler barriers them.
4715
- */
4716
- const SHELL_READ_ONLY_COMMANDS = new Set([
4717
- "ls",
4718
- "cat",
4719
- "head",
4720
- "tail",
4721
- "wc",
4722
- "pwd",
4723
- "whoami",
4724
- "id",
4725
- "date",
4726
- "uname",
4727
- "hostname",
4728
- "tty",
4729
- "echo",
4730
- "printf",
4731
- "env",
4732
- "printenv",
4733
- "which",
4734
- "type",
4735
- "command",
4736
- "file",
4737
- "stat",
4738
- "grep",
4739
- "rg",
4740
- "ag",
4741
- "true",
4742
- "false",
4743
- "test"
4744
- ]);
4745
- /**
4746
- * `git` subcommands that are pure reads regardless of arguments. Excludes
4747
- * `branch`/`tag`/`remote` (which can mutate when given a name) and
4748
- * `config` (which writes when given a value).
4749
- */
4750
- const GIT_READ_ONLY_SUBCOMMANDS = new Set([
4751
- "status",
4752
- "log",
4753
- "diff",
4754
- "show",
4755
- "blame",
4756
- "rev-parse",
4757
- "ls-files",
4758
- "ls-tree",
4759
- "cat-file",
4760
- "reflog",
4761
- "shortlog",
4762
- "describe",
4763
- "rev-list",
4764
- "name-rev",
4765
- "whatchanged",
4766
- "merge-base",
4767
- "symbolic-ref"
4768
- ]);
4769
- /**
4770
- * Conservative read-only verdict for a shell command — used to opt a
4771
- * `shell` invocation into the scheduler's concurrent fleet. Returns
4772
- * `false` (fail-closed) on anything ambiguous so the scheduler barriers
4773
- * it. Specifically:
4774
- *
4775
- * - Rejects compound commands (`;`, `&&`, `||`, `|`) and redirects (`>`,
4776
- * `>>`, `<`) — even a pipe to a read-only sink is treated as too
4777
- * complex to analyze.
4778
- * - Rejects subshell / process substitution (`$(...)`, `` `...` ``,
4779
- * `<(...)`, `>(...)`).
4780
- * - Skips leading `VAR=value` env assignments to find the real
4781
- * command token.
4782
- * - Strips a possible absolute path on the command (`/usr/bin/ls` → `ls`).
4783
- * - Allows the command iff its base name is in
4784
- * {@link SHELL_READ_ONLY_COMMANDS} OR it's `git <subcmd>` where
4785
- * `<subcmd>` is in {@link GIT_READ_ONLY_SUBCOMMANDS}.
4786
- *
4787
- * Cheap (no spawned process; regex + token scan). Safe to call from the
4788
- * hot scheduler path.
4789
- */
4790
- function isReadOnlyShellCommand(command) {
4791
- if (typeof command !== "string") return false;
4792
- const trimmed = command.trim();
4793
- if (trimmed === "") return false;
4794
- if (/[<>;&|`\n]/.test(trimmed)) return false;
4795
- if (trimmed.includes("$(") || trimmed.includes("<(") || trimmed.includes(">(")) return false;
4796
- const tokens = trimmed.split(/\s+/);
4797
- let i = 0;
4798
- while (i < tokens.length && /^[A-Z_]\w*=/i.test(tokens[i])) i++;
4799
- const head = tokens[i];
4800
- if (!head) return false;
4801
- const base = head.split("/").pop() ?? head;
4802
- if (SHELL_READ_ONLY_COMMANDS.has(base)) return true;
4803
- if (base === "git") {
4804
- const sub = tokens[i + 1];
4805
- return typeof sub === "string" && GIT_READ_ONLY_SUBCOMMANDS.has(sub);
4806
- }
4807
- return false;
4808
- }
4809
- const shell = {
4810
- isConcurrencySafe: (input) => isReadOnlyShellCommand(input.command),
5364
+ //#region src/tools/shell-kill.ts
5365
+ const shellKill = {
5366
+ isConcurrencySafe: true,
4811
5367
  spec: {
4812
- name: "shell",
4813
- 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"),
4814
5375
  inputSchema: {
4815
5376
  type: "object",
4816
- properties: {
4817
- command: {
4818
- type: "string",
4819
- description: "Shell command to run."
4820
- },
4821
- timeout: {
4822
- type: "integer",
4823
- description: "Per-call timeout in milliseconds."
4824
- },
4825
- maxOutputBytes: {
4826
- type: "integer",
4827
- description: "Truncate combined stdout+stderr beyond this many bytes. Default: 32768. Set 0 for unlimited."
4828
- },
4829
- metadata: {
4830
- type: "boolean",
4831
- description: "Append `(exit N, Nms)` footer and surface non-empty stderr on success. Default: true."
4832
- }
4833
- },
4834
- 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
4835
5383
  }
4836
5384
  },
4837
- async execute({ command, timeout, maxOutputBytes, metadata }, ctx) {
4838
- const execOpts = {};
4839
- if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) execOpts.timeout = Math.max(1, Math.ceil(timeout / 1e3));
4840
- const cmd = command;
4841
- const wantMetadata = metadata !== false;
4842
- const startedAt = Date.now();
4843
- const result = await ctx.execution.exec(ctx.handle, cmd, execOpts);
4844
- const durationMs = Date.now() - startedAt;
4845
- const cap = normalizeCap(maxOutputBytes);
4846
- const semantic = interpretShellResult(cmd, result.exitCode);
4847
- if (result.exitCode === 0) {
4848
- const stdoutTail = truncateTail(result.stdout || "(no output)", cap);
4849
- if (!wantMetadata) return stdoutTail;
4850
- const stderrTrimmed = result.stderr.trim();
4851
- return `${stdoutTail}${stderrTrimmed ? `\n[stderr]\n${truncateTail(stderrTrimmed, Math.min(cap, 2048))}` : ""}\n(exit 0, ${durationMs}ms)`;
4852
- }
4853
- if (!semantic.isError) {
4854
- const tail = truncateTail((result.stdout || result.stderr || "").trim(), cap);
4855
- const semanticFooter = semantic.message ? `\n(${semantic.message})` : "";
4856
- const timingFooter = wantMetadata ? `\n(exit ${result.exitCode}, ${durationMs}ms)` : "";
4857
- return `${tail.length > 0 ? tail : semantic.message ?? "(no output)"}${semanticFooter}${timingFooter}`;
4858
- }
4859
- const combined = `${result.stdout}\n${result.stderr}`.trim();
4860
- 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");
4861
5395
  }
4862
5396
  };
4863
- function normalizeCap(value) {
4864
- if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_MAX_OUTPUT_BYTES;
4865
- if (value < 0) return DEFAULT_MAX_OUTPUT_BYTES;
4866
- return Math.floor(value);
4867
- }
4868
- /**
4869
- * Tail-priority byte truncation. When `text` exceeds `cap` bytes, the head is
4870
- * dropped and replaced with a marker. Always cuts on character boundaries (no
4871
- * mid-codepoint splits) by walking from the end with `Buffer.byteLength`.
4872
- *
4873
- * `cap === 0` disables truncation. `cap` is interpreted as a UTF-8 byte budget
4874
- * for the tail itself — the marker is added on top and may push the visible
4875
- * length slightly past `cap`. That tradeoff is intentional: a marker that
4876
- * always fits inside the budget would shrink the actual content displayed.
4877
- */
4878
- function truncateTail(text, cap) {
4879
- if (cap === 0) return text;
4880
- const totalBytes = Buffer.byteLength(text);
4881
- if (totalBytes <= cap) return text;
4882
- let bytes = 0;
4883
- let charIdx = text.length;
4884
- while (charIdx > 0) {
4885
- const ch = text[charIdx - 1];
4886
- const chBytes = Buffer.byteLength(ch);
4887
- if (bytes + chBytes > cap) break;
4888
- bytes += chBytes;
4889
- charIdx--;
4890
- }
4891
- const tail = text.slice(charIdx);
4892
- return `…(${totalBytes - Buffer.byteLength(tail)} bytes truncated from head)…\n${tail}`;
4893
- }
4894
5397
  //#endregion
4895
5398
  //#region src/tools/spawn.ts
4896
5399
  const BUBBLED_EVENTS = [
@@ -4902,6 +5405,10 @@ const BUBBLED_EVENTS = [
4902
5405
  "tool:before",
4903
5406
  "tool:after",
4904
5407
  "tool:error",
5408
+ "tool:cancelled",
5409
+ "background:start",
5410
+ "background:exit",
5411
+ "background:reassign",
4905
5412
  "turn:after"
4906
5413
  ];
4907
5414
  const BUBBLED_MUTABLE_EVENTS = [
@@ -4918,6 +5425,10 @@ const CHILD_EVENT_NAME = {
4918
5425
  "tool:before": "child:tool:before",
4919
5426
  "tool:after": "child:tool:after",
4920
5427
  "tool:error": "child:tool:error",
5428
+ "tool:cancelled": "child:tool:cancelled",
5429
+ "background:start": "child:background:start",
5430
+ "background:exit": "child:background:exit",
5431
+ "background:reassign": "child:background:reassign",
4921
5432
  "turn:after": "child:turn:after"
4922
5433
  };
4923
5434
  const CHILD_MUTABLE_EVENT_NAME = {
@@ -5146,7 +5657,7 @@ function createSpawnTool(options = {}) {
5146
5657
  });
5147
5658
  if (forwardHooks) {
5148
5659
  const unregisterEnricher = agent.hooks.hook("tool:before", async (toolCtx) => {
5149
- if (toolCtx.name !== "write_file") return;
5660
+ if (toolCtx.name !== "write_file" && toolCtx.name !== "edit" && toolCtx.name !== "multi_edit") return;
5150
5661
  if (!agent.handle) return;
5151
5662
  const inputPath = toolCtx.input?.path;
5152
5663
  if (typeof inputPath !== "string") return;
@@ -5232,6 +5743,24 @@ function createSpawnTool(options = {}) {
5232
5743
  });
5233
5744
  }
5234
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
+ }
5235
5764
  try {
5236
5765
  await agent.destroy();
5237
5766
  } catch (err) {
@@ -5321,6 +5850,6 @@ const writeFile$1 = {
5321
5850
  }
5322
5851
  };
5323
5852
  //#endregion
5324
- export { readStateKey as A, PERSISTENCE_PREVIEW_BYTES as C, resolvePersistDir as D, maybePersistToolResult as E, getReadState as O, PERSISTED_STUB_PREFIX as S, cleanupPersistedSession as T, createSkillsReadTool as _, multiEdit as a, TOOL_USE_SKIPPED_MESSAGE as b, grep as c, resolveOldString as d, styleReplacementForVia as f, createSkillsRunScriptTool as g, createSkillsUseTool as h, readFile$1 as i, resolveReadStateMap as j, hashContent 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, buildPersistedStub as w, validateToolArgs 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 };
5325
5854
 
5326
- //# sourceMappingURL=tools-BNfyY14s.js.map
5855
+ //# sourceMappingURL=tools-D_icxa-V.js.map