zidane 5.3.1 → 5.4.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 (47) hide show
  1. package/README.md +18 -1
  2. package/dist/{agent-bKs7MRT2.d.ts → agent-DHQAsdj6.d.ts} +174 -15
  3. package/dist/agent-DHQAsdj6.d.ts.map +1 -0
  4. package/dist/chat.d.ts +4 -4
  5. package/dist/chat.d.ts.map +1 -1
  6. package/dist/chat.js +1 -1
  7. package/dist/{index-CTmNaIDb.d.ts → index-CHSaLab5.d.ts} +2 -2
  8. package/dist/{index-CTmNaIDb.d.ts.map → index-CHSaLab5.d.ts.map} +1 -1
  9. package/dist/{index-BlMvPh9X.d.ts → index-CrqFoaQA.d.ts} +36 -11
  10. package/dist/index-CrqFoaQA.d.ts.map +1 -0
  11. package/dist/index.d.ts +4 -4
  12. package/dist/index.js +5 -5
  13. package/dist/{login-CNS9_8Ue.js → login-bK0EP8La.js} +3 -3
  14. package/dist/{login-CNS9_8Ue.js.map → login-bK0EP8La.js.map} +1 -1
  15. package/dist/{mcp-ZsSFo4Dp.js → mcp-DhmmJfxK.js} +15 -2
  16. package/dist/mcp-DhmmJfxK.js.map +1 -0
  17. package/dist/mcp.d.ts +1 -1
  18. package/dist/mcp.js +1 -1
  19. package/dist/{presets-h5i3kpOP.js → presets-M8f6lDnW.js} +2 -2
  20. package/dist/{presets-h5i3kpOP.js.map → presets-M8f6lDnW.js.map} +1 -1
  21. package/dist/presets.d.ts +2 -2
  22. package/dist/presets.js +1 -1
  23. package/dist/providers.d.ts +1 -1
  24. package/dist/session/sqlite.d.ts +1 -1
  25. package/dist/session.d.ts +1 -1
  26. package/dist/skills.d.ts +2 -2
  27. package/dist/{tools-CWEDS2ZT.js → tools-DKdyPoUf.js} +425 -165
  28. package/dist/tools-DKdyPoUf.js.map +1 -0
  29. package/dist/tools.d.ts +3 -3
  30. package/dist/tools.js +2 -2
  31. package/dist/{transcript-anchors-DOUqyvXR.d.ts → transcript-anchors-Fgh_rZ04.d.ts} +3 -3
  32. package/dist/{transcript-anchors-DOUqyvXR.d.ts.map → transcript-anchors-Fgh_rZ04.d.ts.map} +1 -1
  33. package/dist/tui.d.ts +2 -2
  34. package/dist/tui.js +4 -4
  35. package/dist/tui.js.map +1 -1
  36. package/dist/{turn-operations-D9HvatsR.js → turn-operations-DDokWR8p.js} +5 -4
  37. package/dist/turn-operations-DDokWR8p.js.map +1 -0
  38. package/dist/types-IcokUOyC.js.map +1 -1
  39. package/dist/types.d.ts +3 -3
  40. package/docs/ARCHITECTURE.md +18 -7
  41. package/docs/SKILL.md +56 -3
  42. package/package.json +1 -1
  43. package/dist/agent-bKs7MRT2.d.ts.map +0 -1
  44. package/dist/index-BlMvPh9X.d.ts.map +0 -1
  45. package/dist/mcp-ZsSFo4Dp.js.map +0 -1
  46. package/dist/tools-CWEDS2ZT.js.map +0 -1
  47. package/dist/turn-operations-D9HvatsR.js.map +0 -1
@@ -2,7 +2,7 @@ import { n as createProcessContext } from "./contexts-BwiHIr2w.js";
2
2
  import { a as AgentToolPairingError, l as toTypedError, r as AgentProviderError, s as errorMessage, t as AgentAbortedError } from "./errors-Byb0F8B9.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-D0xT979U.js";
5
- import { t as connectMcpServers } from "./mcp-ZsSFo4Dp.js";
5
+ import { t as connectMcpServers } from "./mcp-DhmmJfxK.js";
6
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";
7
7
  import { n as formatTokenUsage, t as flattenTurns } from "./stats-DgOvY7wd.js";
8
8
  import { createHooks } from "hookable";
@@ -107,8 +107,14 @@ function rewriteMessagesToWire(messages, maps) {
107
107
  const STATE = /* @__PURE__ */ new WeakMap();
108
108
  /**
109
109
  * Get or lazily create the per-session read-state map. Returns `undefined`
110
- * when no session is provided — tools should treat that as "no dedup, no
111
- * guard": the state has nowhere to live, so every read is fresh.
110
+ * when no session is provided.
111
+ *
112
+ * Most tool callers should prefer {@link resolveReadStateMap}, which
113
+ * additionally honors an explicit `ctx.readState` (the path
114
+ * `spawn`'s `shareReadState: true` uses to forward the parent's map
115
+ * into a sessionless child). Use this helper directly only when the
116
+ * Session-keyed map is exactly what you want and you don't need to
117
+ * accept an override.
112
118
  */
113
119
  function getReadState(session) {
114
120
  if (!session) return void 0;
@@ -119,19 +125,38 @@ function getReadState(session) {
119
125
  }
120
126
  return map;
121
127
  }
122
- const TOOL_DEDUP_STATE = /* @__PURE__ */ new WeakMap();
123
128
  /**
124
- * Get or lazily create the per-session tool-dedup map. Returns `undefined`
125
- * when no session is provided middleware should treat that as "no dedup".
129
+ * Resolve the active read-state map from a tool context. An explicit
130
+ * `ctx.readState` wins (used by `spawn`'s `shareReadState` opt-in to
131
+ * forward the parent's map into a sessionless child); otherwise the
132
+ * usual `Session`-keyed lookup applies.
133
+ *
134
+ * Tools should call this helper instead of `getReadState(ctx.session)`
135
+ * directly so the `shareReadState` plumbing is honored uniformly.
126
136
  */
127
- function getToolDedupState(session) {
128
- if (!session) return void 0;
129
- let map = TOOL_DEDUP_STATE.get(session);
130
- if (!map) {
131
- map = /* @__PURE__ */ new Map();
132
- TOOL_DEDUP_STATE.set(session, map);
133
- }
134
- return map;
137
+ function resolveReadStateMap(ctx) {
138
+ return ctx.readState ?? getReadState(ctx.session);
139
+ }
140
+ /**
141
+ * Canonical read-state key for a `(cwd, path)` pair.
142
+ *
143
+ * `ctx.execution.readFile(handle, path)` resolves the model-provided
144
+ * path against `handle.cwd` before touching disk — so `src/App.tsx`,
145
+ * `./src/App.tsx`, and `/abs/cwd/src/App.tsx` all read the **same**
146
+ * bytes. The read-state map MUST mirror that resolution: without it,
147
+ * a model that reads `src/App.tsx` and then edits `./src/App.tsx`
148
+ * trips the `requireReadBeforeEdit` gate for a file it demonstrably
149
+ * already saw (the gate's "has not been read in this session" message
150
+ * fires on a stale key).
151
+ *
152
+ * `node:path`'s `resolve(cwd, path)` short-circuits when `path` is
153
+ * already absolute, so absolute and relative shapes converge on the
154
+ * same canonical form. We don't `realpath()` symlinks — the file IS
155
+ * the path the model addressed, not the realpath behind it; the host's
156
+ * execution context decides how to dereference.
157
+ */
158
+ function readStateKey(cwd, path) {
159
+ return resolve(cwd, path);
135
160
  }
136
161
  /**
137
162
  * FNV-1a 32-bit hash, hex-encoded. Fast, non-cryptographic — we only need
@@ -147,6 +172,20 @@ function hashContent(text) {
147
172
  }
148
173
  return h.toString(16).padStart(8, "0");
149
174
  }
175
+ const TOOL_DEDUP_STATE = /* @__PURE__ */ new WeakMap();
176
+ /**
177
+ * Get or lazily create the per-session tool-dedup map. Returns `undefined`
178
+ * when no session is provided — middleware should treat that as "no dedup".
179
+ */
180
+ function getToolDedupState(session) {
181
+ if (!session) return void 0;
182
+ let map = TOOL_DEDUP_STATE.get(session);
183
+ if (!map) {
184
+ map = /* @__PURE__ */ new Map();
185
+ TOOL_DEDUP_STATE.set(session, map);
186
+ }
187
+ return map;
188
+ }
150
189
  //#endregion
151
190
  //#region src/dedup-tools.ts
152
191
  /**
@@ -766,20 +805,13 @@ const IMAGE_OMITTED_MARKER = "[image omitted — model does not support vision]"
766
805
  const INTERRUPT_MESSAGE_FOR_TOOL_USE = "[Request interrupted by user for tool use]";
767
806
  /**
768
807
  * Canonical tool_result text emitted when a tool call is skipped because a
769
- * sibling sequential call errored or a steering message arrived between
770
- * iterations of {@link executeToolsSequential}. Distinguished from
771
- * {@link INTERRUPT_MESSAGE_FOR_TOOL_USE} so consumers can distinguish "user
808
+ * steering message arrived between dispatches inside
809
+ * {@link executeToolBatch}. Distinguished from
810
+ * {@link INTERRUPT_MESSAGE_FOR_TOOL_USE} so consumers can split "user
772
811
  * cancelled" from "framework superseded".
773
812
  */
774
813
  const TOOL_USE_SKIPPED_MESSAGE = "[Tool use skipped — superseded by user message]";
775
814
  /**
776
- * Canonical tool_result text emitted when the loop catches a sequential
777
- * sibling that threw and synthesizes follow-up results for the remaining
778
- * queued calls. Distinct from {@link TOOL_USE_SKIPPED_MESSAGE} so telemetry
779
- * can split "skipped by user steering" from "skipped after error".
780
- */
781
- const TOOL_USE_AFTER_ERROR_MESSAGE = "[Tool use skipped — previous tool call in batch threw]";
782
- /**
783
815
  * Compute the effective thinking budget for a given run-relative turn, given
784
816
  * the configured decay schedule. Pure helper — exported for tests and so
785
817
  * downstream tooling can preview decay curves without spinning up the loop.
@@ -1067,19 +1099,21 @@ function applyStaleReadElision(messages) {
1067
1099
  }
1068
1100
  /**
1069
1101
  * Drop read-state entries for paths whose reads got elided. Keys are
1070
- * `${cwd}::${path}` (see `src/tools/read-file.ts`); we don't know the
1071
- * cwd in this context, so we match by `::<path>` suffix. Safe because
1072
- * paths inside the read-state map come from a single session bound to
1073
- * one cwd in practice; an accidental cross-cwd collision would also
1074
- * have to share an identical relative path, in which case force-fresh
1075
- * is the correct behavior anyway.
1102
+ * canonical absolute paths produced by `readStateKey(cwd, path)` (see
1103
+ * `src/tools/read-state.ts`); the loop has `ctx.handle.cwd` in scope,
1104
+ * so we re-derive each elided path's canonical key and delete by
1105
+ * direct lookup. No suffix matching, no cross-cwd ambiguity.
1106
+ *
1107
+ * Resolves the map via `resolveReadStateMap` so a child agent running
1108
+ * with a parent's shared map (via `shareReadState`) invalidates the
1109
+ * shared entries too — otherwise a child's `read → edit → re-read`
1110
+ * would dedup-hit on the now-stale parent entry.
1076
1111
  */
1077
- function invalidateReadStateForElidedPaths(session, elidedPaths) {
1078
- if (!session || elidedPaths.length === 0) return;
1079
- const readState = getReadState(session);
1112
+ function invalidateReadStateForElidedPaths(ctx, cwd, elidedPaths) {
1113
+ if (elidedPaths.length === 0) return;
1114
+ const readState = resolveReadStateMap(ctx);
1080
1115
  if (!readState || readState.size === 0) return;
1081
- const suffixes = elidedPaths.map((p) => `::${p}`);
1082
- for (const key of [...readState.keys()]) if (suffixes.some((s) => key.endsWith(s))) readState.delete(key);
1116
+ for (const p of elidedPaths) readState.delete(readStateKey(cwd, p));
1083
1117
  }
1084
1118
  /**
1085
1119
  * Run {@link ensureToolResultPairing} with the loop's hook + strict-mode
@@ -1265,7 +1299,7 @@ async function executeTurn(ctx, turn) {
1265
1299
  if (ctx.elideStaleReads === true) {
1266
1300
  const elision = applyStaleReadElision(canonicalMessages);
1267
1301
  canonicalMessages = elision.messages;
1268
- invalidateReadStateForElidedPaths(ctx.session, elision.elidedPaths);
1302
+ invalidateReadStateForElidedPaths(ctx, ctx.handle.cwd, elision.elidedPaths);
1269
1303
  }
1270
1304
  const wireMessages = rewriteMessagesToWire(canonicalMessages, ctx.aliasMaps);
1271
1305
  let sanitizedMessages = sanitizeStoredToolResults(ctx.provider, wireMessages);
@@ -1477,7 +1511,7 @@ async function executeTurn(ctx, turn) {
1477
1511
  usage: result.usage
1478
1512
  };
1479
1513
  }
1480
- const toolResults = ctx.toolExecution === "parallel" ? await executeToolsParallel(ctx, canonicalToolCalls, turnId) : await executeToolsSequential(ctx, canonicalToolCalls, turnId);
1514
+ const toolResults = await executeToolBatch(ctx, canonicalToolCalls, turnId);
1481
1515
  const toolResultMsg = ctx.provider.toolResultsMessage(toolResults);
1482
1516
  const toolResultsTurn = {
1483
1517
  id: await ctx.generateTurnId(),
@@ -1539,17 +1573,35 @@ function stripImagesForNonVision(provider, output) {
1539
1573
  if (provider.meta.capabilities?.vision !== false) return output;
1540
1574
  return output.map((b) => b.type === "image" ? IMAGE_OMITTED_MARKER : b.text).join("\n");
1541
1575
  }
1576
+ /**
1577
+ * Build the per-call base for every `tool:*` hook ctx (and the
1578
+ * matching shape for `mcp:tool:*`). Centralized so the `runId` /
1579
+ * `parentRunId` / `depth` identity fields land uniformly on every
1580
+ * event the loop fires — without one helper they drift across ~14
1581
+ * inline construction sites. The returned object IS the
1582
+ * {@link ToolHookContext} canonical shape; specialized hook payloads
1583
+ * (`gateCtx`, `transformCtx`, etc.) spread it and append their own
1584
+ * fields.
1585
+ */
1586
+ function buildToolHookBase(ctx, turnId, callId, name, displayName, input) {
1587
+ return {
1588
+ turnId,
1589
+ callId,
1590
+ name,
1591
+ displayName,
1592
+ input,
1593
+ ...ctx.runId !== void 0 ? { runId: ctx.runId } : {},
1594
+ ...ctx.parentRunId !== void 0 ? { parentRunId: ctx.parentRunId } : {},
1595
+ ...typeof ctx.depth === "number" ? { depth: ctx.depth } : {}
1596
+ };
1597
+ }
1542
1598
  async function executeSingleTool(ctx, call, turnId) {
1543
1599
  const toolDef = ctx.tools[call.name];
1544
1600
  const callId = call.id;
1545
1601
  const displayName = toWireName(call.name, ctx.aliasMaps);
1546
1602
  const runToolCounts = Object.freeze({ ...ctx.runToolCounts });
1547
1603
  const gateCtx = {
1548
- turnId,
1549
- callId,
1550
- name: call.name,
1551
- displayName,
1552
- input: call.input,
1604
+ ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, call.input),
1553
1605
  block: false,
1554
1606
  reason: "Tool execution was blocked",
1555
1607
  runToolCounts
@@ -1602,11 +1654,7 @@ async function executeSingleTool(ctx, call, turnId) {
1602
1654
  let effectiveInput = gateCtx.input;
1603
1655
  if (!toolDef) {
1604
1656
  const unknownCtx = {
1605
- turnId,
1606
- callId,
1607
- name: call.name,
1608
- displayName,
1609
- input: effectiveInput,
1657
+ ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, effectiveInput),
1610
1658
  suppressError: false
1611
1659
  };
1612
1660
  await ctx.hooks.callHook("tool:unknown", unknownCtx);
@@ -1615,11 +1663,7 @@ async function executeSingleTool(ctx, call, turnId) {
1615
1663
  if (!unknownCtx.suppressError) {
1616
1664
  const err = /* @__PURE__ */ new Error(`Unknown tool: ${call.name}`);
1617
1665
  await ctx.hooks.callHook("tool:error", {
1618
- turnId,
1619
- callId,
1620
- name: call.name,
1621
- displayName,
1622
- input: effectiveInput,
1666
+ ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, effectiveInput),
1623
1667
  error: err
1624
1668
  });
1625
1669
  }
@@ -1641,11 +1685,7 @@ async function executeSingleTool(ctx, call, turnId) {
1641
1685
  const validation = validateToolArgs(effectiveInput, toolDef.spec.inputSchema);
1642
1686
  if (!validation.valid) {
1643
1687
  await ctx.hooks.callHook("validation:reject", {
1644
- turnId,
1645
- callId,
1646
- name: call.name,
1647
- displayName,
1648
- input: effectiveInput,
1688
+ ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, effectiveInput),
1649
1689
  reason: validation.error ?? "invalid input",
1650
1690
  schema: toolDef.spec.inputSchema
1651
1691
  });
@@ -1667,11 +1707,7 @@ async function executeSingleTool(ctx, call, turnId) {
1667
1707
  effectiveInput = validation.coercedInput ?? effectiveInput;
1668
1708
  const coercions = validation.coercions && validation.coercions.length > 0 ? validation.coercions : void 0;
1669
1709
  if (coercions) await ctx.hooks.callHook("validation:coerce", {
1670
- turnId,
1671
- callId,
1672
- name: call.name,
1673
- displayName,
1674
- input: effectiveInput,
1710
+ ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, effectiveInput),
1675
1711
  coercions,
1676
1712
  schema: toolDef.spec.inputSchema
1677
1713
  });
@@ -1685,11 +1721,7 @@ async function executeSingleTool(ctx, call, turnId) {
1685
1721
  runToolCounts
1686
1722
  });
1687
1723
  await ctx.hooks.callHook("tool:before", {
1688
- turnId,
1689
- callId,
1690
- name: call.name,
1691
- displayName,
1692
- input: effectiveInput,
1724
+ ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, effectiveInput),
1693
1725
  runToolCounts,
1694
1726
  ...coercions ? { coercions } : {}
1695
1727
  });
@@ -1712,18 +1744,16 @@ async function executeSingleTool(ctx, call, turnId) {
1712
1744
  turnId,
1713
1745
  callId,
1714
1746
  runId: ctx.runId,
1747
+ ...ctx.parentRunId !== void 0 ? { parentRunId: ctx.parentRunId } : {},
1715
1748
  ...ctx.session ? { session: ctx.session } : {},
1749
+ ...ctx.readState ? { readState: ctx.readState } : {},
1716
1750
  ...typeof ctx.depth === "number" ? { depth: ctx.depth } : {}
1717
1751
  };
1718
1752
  output = await toolDef.execute(effectiveInput, toolCtx);
1719
1753
  } catch (err) {
1720
1754
  const error = err instanceof Error ? err : new Error(String(err));
1721
1755
  const errorCtx = {
1722
- turnId,
1723
- callId,
1724
- name: call.name,
1725
- displayName,
1726
- input: effectiveInput,
1756
+ ...buildToolHookBase(ctx, turnId, callId, call.name, displayName, effectiveInput),
1727
1757
  error
1728
1758
  };
1729
1759
  await ctx.hooks.callHook("tool:error", errorCtx);
@@ -1761,9 +1791,11 @@ async function executeSingleTool(ctx, call, turnId) {
1761
1791
  * across five call sites.
1762
1792
  */
1763
1793
  async function fireDispatched(ctx, params) {
1764
- const { reason, ...rest } = params;
1794
+ const { turnId, callId, name, displayName, input, outcome, reason, runToolCounts } = params;
1765
1795
  await ctx.hooks.callHook("tool:dispatched", {
1766
- ...rest,
1796
+ ...buildToolHookBase(ctx, turnId, callId, name, displayName, input),
1797
+ outcome,
1798
+ runToolCounts,
1767
1799
  ...reason !== void 0 ? { reason } : {}
1768
1800
  });
1769
1801
  }
@@ -1784,11 +1816,7 @@ async function emitToolResult(ctx, params) {
1784
1816
  let output = params.output;
1785
1817
  let isError = params.isError;
1786
1818
  const transformCtx = {
1787
- turnId,
1788
- callId,
1789
- name,
1790
- displayName,
1791
- input,
1819
+ ...buildToolHookBase(ctx, turnId, callId, name, displayName, input),
1792
1820
  result: output,
1793
1821
  isError,
1794
1822
  outputBytes: toolOutputByteLength(output),
@@ -1813,11 +1841,7 @@ async function emitToolResult(ctx, params) {
1813
1841
  }
1814
1842
  output = stripImagesForNonVision(ctx.provider, output);
1815
1843
  await ctx.hooks.callHook("tool:after", {
1816
- turnId,
1817
- callId,
1818
- name,
1819
- displayName,
1820
- input,
1844
+ ...buildToolHookBase(ctx, turnId, callId, name, displayName, input),
1821
1845
  result: output,
1822
1846
  outputBytes: toolOutputByteLength(output),
1823
1847
  runToolCounts,
@@ -1828,57 +1852,170 @@ async function emitToolResult(ctx, params) {
1828
1852
  isError
1829
1853
  };
1830
1854
  }
1831
- async function executeToolsSequential(ctx, toolCalls, turnId) {
1832
- const results = [];
1833
- for (let i = 0; i < toolCalls.length; i++) {
1834
- const call = toolCalls[i];
1835
- if (ctx.signal.aborted) {
1836
- for (let j = i; j < toolCalls.length; j++) results.push({
1837
- id: toolCalls[j].id,
1838
- content: INTERRUPT_MESSAGE_FOR_TOOL_USE,
1839
- isError: true
1840
- });
1841
- return results;
1842
- }
1843
- if (ctx.steeringQueue.length > 0) {
1844
- for (let j = i; j < toolCalls.length; j++) results.push({
1845
- id: toolCalls[j].id,
1846
- content: TOOL_USE_SKIPPED_MESSAGE,
1847
- isError: true
1848
- });
1849
- return results;
1850
- }
1851
- try {
1852
- const { result } = await executeSingleTool(ctx, call, turnId);
1853
- results.push(result);
1854
- } catch (err) {
1855
- results.push({
1856
- id: call.id,
1857
- content: `Error: ${errorMessage(err)}`,
1858
- isError: true
1859
- });
1860
- for (let j = i + 1; j < toolCalls.length; j++) results.push({
1861
- id: toolCalls[j].id,
1862
- content: TOOL_USE_AFTER_ERROR_MESSAGE,
1863
- isError: true
1864
- });
1865
- return results;
1866
- }
1855
+ /** Default cap on in-flight tools per turn. Mirrors Claude Code's `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`. */
1856
+ const DEFAULT_MAX_CONCURRENT_TOOLS = 10;
1857
+ /** Canonical name of the shell tool — referenced for cascade-cancel semantics. */
1858
+ const SHELL_TOOL_NAME = "shell";
1859
+ /** Reason surfaced on `siblingAbort.signal` when a shell error cancels its fleet. */
1860
+ const SHELL_CASCADE_REASON = "sibling-shell-error";
1861
+ /**
1862
+ * Canonical `tool_result.content` text emitted to siblings that were
1863
+ * cancelled by a `shell` error in the same batch. Distinct from
1864
+ * {@link INTERRUPT_MESSAGE_FOR_TOOL_USE} (user-issued abort) and
1865
+ * {@link TOOL_USE_SKIPPED_MESSAGE} (steered) so consumers can split
1866
+ * the three causes by string-match.
1867
+ */
1868
+ const SHELL_CASCADE_CANCEL_MESSAGE = "Cancelled: a sibling `shell` call in the same batch errored; re-run independently if still needed.";
1869
+ /**
1870
+ * Resolve a tool's concurrency-safety verdict for a specific call.
1871
+ *
1872
+ * - Missing toolDef (unknown tool) → `false`. `executeSingleTool` handles
1873
+ * the unknown-tool path itself; barriering it keeps the unknown-tool
1874
+ * error from racing with siblings.
1875
+ * - Static `true` / `false` → use as-is.
1876
+ * - Function invoke; any throw is treated as `false` (fail-closed) so a
1877
+ * buggy predicate can't accidentally widen the safety window.
1878
+ *
1879
+ * Pure / sync — pre-computed once per call before dispatch begins, so the
1880
+ * scheduler's hot path stays branch-light.
1881
+ */
1882
+ function resolveConcurrencySafe(def, input) {
1883
+ if (!def) return false;
1884
+ const flag = def.isConcurrencySafe;
1885
+ if (flag === void 0) return false;
1886
+ if (typeof flag === "boolean") return flag;
1887
+ try {
1888
+ return flag(input) === true;
1889
+ } catch {
1890
+ return false;
1867
1891
  }
1868
- return results;
1869
1892
  }
1870
- async function executeToolsParallel(ctx, toolCalls, turnId) {
1871
- const executions = toolCalls.map((call) => executeSingleTool(ctx, call, turnId));
1872
- return (await Promise.allSettled(executions)).map((s, i) => {
1873
- if (s.status === "fulfilled") return s.value.result;
1874
- const reason = s.reason;
1875
- const isAbort = ctx.signal.aborted || reason instanceof Error && reason.name === "AbortError";
1876
- return {
1877
- id: toolCalls[i].id,
1878
- content: isAbort ? INTERRUPT_MESSAGE_FOR_TOOL_USE : `Error: ${reason instanceof Error ? reason.message : String(reason)}`,
1893
+ /**
1894
+ * Unified per-turn tool dispatcher.
1895
+ *
1896
+ * Walks `toolCalls` in submission order. For each call:
1897
+ *
1898
+ * - **Concurrency-safe + fleet is all safe + room under the cap** → fires
1899
+ * asynchronously and the loop advances to the next call. The fleet runs
1900
+ * in parallel up to `behavior.maxConcurrentTools` (default {@link
1901
+ * DEFAULT_MAX_CONCURRENT_TOOLS}).
1902
+ * - **Unsafe** (or the in-flight fleet contains anything unsafe) → acts
1903
+ * as a barrier: waits for the fleet to drain, then runs alone, then
1904
+ * unblocks the queue.
1905
+ *
1906
+ * Results are written into a fixed `results[index]` array on completion
1907
+ * and yielded back in submission order, so the model sees deterministic
1908
+ * adjacency regardless of which call finished first.
1909
+ *
1910
+ * **Failure modes:**
1911
+ *
1912
+ * - **Hook throws / tool body throws** — captured per-call into a
1913
+ * `tool_result` so the assistant turn's `tool_use` IDs always have
1914
+ * matching `tool_result` IDs (providers reject orphan IDs).
1915
+ * - **Parent abort** mid-batch — drains the in-flight fleet (their
1916
+ * `AbortError` becomes `INTERRUPT_MESSAGE_FOR_TOOL_USE`), then synthesizes
1917
+ * interrupt results for any unstarted calls so the turn closes cleanly.
1918
+ * - **Steering queue populated** between dispatches — same drain + a
1919
+ * `TOOL_USE_SKIPPED_MESSAGE` result for unstarted calls. The outer loop
1920
+ * picks up the steer at the next checkpoint.
1921
+ * - **Shell error in a fleet** — `siblingAbort.abort('sibling-shell-error')`
1922
+ * tears down concurrently-running siblings. Mirrors the convention that
1923
+ * shell commands often chain (`mkdir foo && cd foo`); one failing
1924
+ * sibling commonly invalidates the rest. Non-shell errors are isolated.
1925
+ *
1926
+ * A child `AbortController` (`siblingAbort`) forwards the parent abort
1927
+ * AND carries the shell-cascade signal — siblings see one signal source.
1928
+ */
1929
+ async function executeToolBatch(ctx, toolCalls, turnId) {
1930
+ if (toolCalls.length === 0) return [];
1931
+ const N = toolCalls.length;
1932
+ const maxConcurrent = Math.max(1, ctx.maxConcurrentTools ?? DEFAULT_MAX_CONCURRENT_TOOLS);
1933
+ const results = Array.from({ length: N });
1934
+ const safe = Array.from({ length: N });
1935
+ for (let i = 0; i < N; i++) safe[i] = resolveConcurrencySafe(ctx.tools[toolCalls[i].name], toolCalls[i].input);
1936
+ const siblingAbort = new AbortController();
1937
+ let parentAbortListener;
1938
+ if (ctx.signal.aborted) siblingAbort.abort(ctx.signal.reason ?? "parent-aborted");
1939
+ else {
1940
+ parentAbortListener = () => siblingAbort.abort(ctx.signal.reason ?? "parent-aborted");
1941
+ ctx.signal.addEventListener("abort", parentAbortListener, { once: true });
1942
+ }
1943
+ const childCtx = {
1944
+ ...ctx,
1945
+ signal: siblingAbort.signal
1946
+ };
1947
+ /** Indices currently in flight. Tracked for fleet-safety + cap checks. */
1948
+ const inFlight = /* @__PURE__ */ new Map();
1949
+ /**
1950
+ * Distinguish a shell-cascade kill from a user-issued abort so the
1951
+ * model sees actionable text. When BOTH the parent signal and the
1952
+ * sibling signal are aborted, the parent wins — user-issued aborts
1953
+ * take precedence (the model is being interrupted by the human, not
1954
+ * by a sibling's failure).
1955
+ */
1956
+ const cancelMessage = () => {
1957
+ if (ctx.signal.aborted) return INTERRUPT_MESSAGE_FOR_TOOL_USE;
1958
+ if (siblingAbort.signal.reason === SHELL_CASCADE_REASON) return SHELL_CASCADE_CANCEL_MESSAGE;
1959
+ return INTERRUPT_MESSAGE_FOR_TOOL_USE;
1960
+ };
1961
+ const dispatch = (index) => {
1962
+ const call = toolCalls[index];
1963
+ return executeSingleTool(childCtx, call, turnId).then(({ result }) => {
1964
+ results[index] = result;
1965
+ if (result.isError && call.name === SHELL_TOOL_NAME && !siblingAbort.signal.aborted) siblingAbort.abort(SHELL_CASCADE_REASON);
1966
+ }, (err) => {
1967
+ const isAbort = siblingAbort.signal.aborted || ctx.signal.aborted || err instanceof Error && err.name === "AbortError";
1968
+ results[index] = {
1969
+ id: call.id,
1970
+ content: isAbort ? cancelMessage() : `Error: ${errorMessage(err)}`,
1971
+ isError: true
1972
+ };
1973
+ }).finally(() => {
1974
+ inFlight.delete(index);
1975
+ });
1976
+ };
1977
+ const drain = async () => {
1978
+ if (inFlight.size > 0) await Promise.all([...inFlight.values()]);
1979
+ };
1980
+ /** Whether every in-flight call is concurrency-safe. */
1981
+ const fleetAllSafe = () => {
1982
+ for (const idx of inFlight.keys()) if (!safe[idx]) return false;
1983
+ return true;
1984
+ };
1985
+ /**
1986
+ * Fill all unstarted slots (`results[j]` still undefined) with the
1987
+ * canonical text + `isError: true`. Used at every short-circuit
1988
+ * branch (abort / steer) so the assistant turn's `tool_use` IDs
1989
+ * always have matching `tool_result` IDs — providers reject orphan
1990
+ * IDs loudly.
1991
+ */
1992
+ const fillUnstarted = (from, content) => {
1993
+ for (let j = from; j < N; j++) if (!results[j]) results[j] = {
1994
+ id: toolCalls[j].id,
1995
+ content,
1879
1996
  isError: true
1880
1997
  };
1881
- });
1998
+ };
1999
+ try {
2000
+ for (let i = 0; i < N; i++) {
2001
+ if (!safe[i] || !fleetAllSafe() || inFlight.size >= maxConcurrent) await drain();
2002
+ if (ctx.signal.aborted) {
2003
+ await drain();
2004
+ fillUnstarted(i, INTERRUPT_MESSAGE_FOR_TOOL_USE);
2005
+ return results;
2006
+ }
2007
+ if (ctx.steeringQueue.length > 0) {
2008
+ await drain();
2009
+ fillUnstarted(i, TOOL_USE_SKIPPED_MESSAGE);
2010
+ return results;
2011
+ }
2012
+ inFlight.set(i, dispatch(i));
2013
+ }
2014
+ await drain();
2015
+ return results;
2016
+ } finally {
2017
+ if (parentAbortListener) ctx.signal.removeEventListener("abort", parentAbortListener);
2018
+ }
1882
2019
  }
1883
2020
  //#endregion
1884
2021
  //#region src/prompt.ts
@@ -2098,6 +2235,7 @@ function looksBinary(text, sniffBytes = SNIFF_BYTES) {
2098
2235
  function createSkillsReadTool(options) {
2099
2236
  const byName = new Map(options.catalog.map((s) => [s.name, s]));
2100
2237
  return {
2238
+ isConcurrencySafe: true,
2101
2239
  spec: {
2102
2240
  name: "skills_read",
2103
2241
  description: "Read a bundled resource file from an active skill. The skill must have been activated via skills_use first. Path is relative to the skill's directory (e.g. \"references/REFERENCE.md\", \"assets/template.txt\").",
@@ -2361,6 +2499,7 @@ function createToolSearchTool(options) {
2361
2499
  }
2362
2500
  const maxLimit = Math.max(options.catalog.length, 1);
2363
2501
  return {
2502
+ isConcurrencySafe: true,
2364
2503
  spec: {
2365
2504
  name: "tool_search",
2366
2505
  description: "Discover and load schemas for additional tools listed in <searchable_tools>. Tools listed there are advertised by name + description only — their input schemas are not loaded into context until you surface them through this tool. Pass `query` for a substring search, `names` to load specific tools, or `server` to load every tool from one MCP server. Returned tools become callable for the rest of this run.",
@@ -2576,7 +2715,7 @@ async function synthesizeMissingToolResults(turns, syntheticTurnId, runId, provi
2576
2715
  }
2577
2716
  function resolveBehavior(agentBehavior, runBehavior) {
2578
2717
  return {
2579
- toolExecution: runBehavior?.toolExecution ?? agentBehavior?.toolExecution ?? "parallel",
2718
+ maxConcurrentTools: runBehavior?.maxConcurrentTools ?? agentBehavior?.maxConcurrentTools,
2580
2719
  maxTurns: runBehavior?.maxTurns ?? agentBehavior?.maxTurns,
2581
2720
  maxTokens: runBehavior?.maxTokens ?? agentBehavior?.maxTokens,
2582
2721
  thinkingBudget: runBehavior?.thinkingBudget ?? agentBehavior?.thinkingBudget,
@@ -2745,7 +2884,7 @@ function initialRunCounter(session) {
2745
2884
  for (const t of session.turns) consider(t.runId);
2746
2885
  return max;
2747
2886
  }
2748
- function createAgent({ provider, name: agentName, system: agentSystem, tools: agentTools, toolAliases, behavior: agentBehavior, execution, mcpServers, session, skills: agentSkills, mcpConnector, eager, hooks: initialHooks }) {
2887
+ function createAgent({ provider, name: agentName, system: agentSystem, tools: agentTools, toolAliases, behavior: agentBehavior, execution, mcpServers, session, readState: agentReadState, skills: agentSkills, mcpConnector, eager, hooks: initialHooks }) {
2749
2888
  const hooks = createHooks();
2750
2889
  const executionContext = execution ?? createProcessContext();
2751
2890
  const sourceTools = agentTools ?? {};
@@ -2886,7 +3025,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
2886
3025
  const thinking = options.thinking ?? "off";
2887
3026
  const model = options.model ?? provider.meta.defaultModel;
2888
3027
  const resolvedBehavior = resolveBehavior(agentBehavior, options.behavior);
2889
- const { toolExecution, maxTurns, maxTokens, thinkingBudget, schema, cache, toolOutputBudget, compactStrategy, compactThreshold, compactKeepTurns, thinkingDecay, dedupTools, toolBudgets, elideStaleReads, toolDisclosure, toolSearch, persistThreshold, persistExcludeTools, persistDir, strictToolPairing } = resolvedBehavior;
3028
+ const { maxConcurrentTools, maxTurns, maxTokens, thinkingBudget, schema, cache, toolOutputBudget, compactStrategy, compactThreshold, compactKeepTurns, thinkingDecay, dedupTools, toolBudgets, elideStaleReads, toolDisclosure, toolSearch, persistThreshold, persistExcludeTools, persistDir, strictToolPairing } = resolvedBehavior;
2890
3029
  let system = options.system || agentSystem || "You are a helpful assistant.";
2891
3030
  if (skillsCatalog) system = `${system}\n\n${skillsCatalog}`;
2892
3031
  const runBaseTools = options.tools !== void 0 ? options.tools : mcpConnection ? {
@@ -3049,7 +3188,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3049
3188
  model,
3050
3189
  system,
3051
3190
  thinking,
3052
- toolExecution,
3191
+ ...maxConcurrentTools !== void 0 ? { maxConcurrentTools } : {},
3053
3192
  signal: abortController.signal,
3054
3193
  execution: executionContext,
3055
3194
  handle: executionHandle,
@@ -3061,6 +3200,8 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
3061
3200
  maxTurns,
3062
3201
  maxTokens,
3063
3202
  ...session ? { session } : {},
3203
+ ...agentReadState ? { readState: agentReadState } : {},
3204
+ ...options.parentRunId ? { parentRunId: options.parentRunId } : {},
3064
3205
  depth: runDepth,
3065
3206
  thinkingBudget,
3066
3207
  schema,
@@ -3624,12 +3765,14 @@ const edit = {
3624
3765
  } catch {
3625
3766
  return `Edit error: file not found: ${target}.${await suggestionFor(ctx.execution, ctx.handle, target)}`;
3626
3767
  }
3627
- if (ctx.behavior?.requireReadBeforeEdit && ctx.session) {
3628
- const readState = getReadState(ctx.session);
3629
- const absKey = `${ctx.handle.cwd}::${target}`;
3630
- const prior = readState?.get(absKey);
3631
- if (!prior) return `Edit error: ${target} has not been read in this session. Call read_file first so the edit applies against the current contents.`;
3632
- if (prior.contentHash !== hashContent(original)) return `Edit error: ${target} has changed on disk since the last read. Re-read the file before editing.`;
3768
+ if (ctx.behavior?.requireReadBeforeEdit) {
3769
+ const readState = resolveReadStateMap(ctx);
3770
+ if (readState) {
3771
+ const absKey = readStateKey(ctx.handle.cwd, target);
3772
+ const prior = readState.get(absKey);
3773
+ if (!prior) return `Edit error: ${target} has not been read in this session. Call read_file first so the edit applies against the current contents. (Reads inside a \`spawn\` subagent with \`persist: false\` and without \`shareReadState: true\` do NOT propagate to the parent — re-read in the calling context.)`;
3774
+ if (prior.contentHash !== hashContent(original)) return `Edit error: ${target} has changed on disk since the last read. Re-read the file before editing.`;
3775
+ }
3633
3776
  }
3634
3777
  const match = resolveOldString(original, find);
3635
3778
  if (!match) {
@@ -3642,11 +3785,11 @@ const edit = {
3642
3785
  const updated = replaceAll ? original.split(actual).join(styledReplacement) : original.replace(actual, styledReplacement);
3643
3786
  if (updated === original) return `Edit error: replacement produced no change in ${target}.`;
3644
3787
  await ctx.execution.writeFile(ctx.handle, target, updated);
3645
- if (ctx.session) {
3646
- const readState = getReadState(ctx.session);
3647
- const absKey = `${ctx.handle.cwd}::${target}`;
3648
- const prior = readState?.get(absKey);
3649
- if (readState && prior) readState.set(absKey, {
3788
+ const readState = resolveReadStateMap(ctx);
3789
+ if (readState) {
3790
+ const absKey = readStateKey(ctx.handle.cwd, target);
3791
+ const prior = readState.get(absKey);
3792
+ if (prior) readState.set(absKey, {
3650
3793
  ...prior,
3651
3794
  contentHash: hashContent(updated),
3652
3795
  mtimeMs: Date.now()
@@ -3725,6 +3868,7 @@ async function globViaShell(pattern, ctx, limit) {
3725
3868
  return result.stdout.split("\n").filter((line) => line.length > 0);
3726
3869
  }
3727
3870
  const glob = {
3871
+ isConcurrencySafe: true,
3728
3872
  spec: {
3729
3873
  name: "glob",
3730
3874
  description: "Match files by glob pattern (supports **, *, ?). Relative to the execution context cwd. By default each row is `<path>\\t<size-bytes>\\t<mtime-iso>`; set `metadata: false` for a plain newline-separated list of paths. Always sorted.",
@@ -3790,6 +3934,7 @@ const glob = {
3790
3934
  const DEFAULT_HEAD_LIMIT = 250;
3791
3935
  const DEFAULT_OUTPUT_MODE = "files_with_matches";
3792
3936
  const grep = {
3937
+ isConcurrencySafe: true,
3793
3938
  spec: {
3794
3939
  name: "grep",
3795
3940
  description: "Search file contents by regex. Returns matching paths (default), match content, or per-file counts. Backed by ripgrep when available with a Bun.Glob fallback for in-process runs.",
@@ -4025,6 +4170,7 @@ function createInteractionTool(options) {
4025
4170
  //#endregion
4026
4171
  //#region src/tools/list-files.ts
4027
4172
  const listFiles = {
4173
+ isConcurrencySafe: true,
4028
4174
  spec: {
4029
4175
  name: "list_files",
4030
4176
  description: "List files and directories at the given path (relative to project root).",
@@ -4085,12 +4231,14 @@ const multiEdit = {
4085
4231
  } catch {
4086
4232
  return `multi_edit error: file not found: ${target}.${await suggestionFor(ctx.execution, ctx.handle, target)}`;
4087
4233
  }
4088
- if (ctx.behavior?.requireReadBeforeEdit && ctx.session) {
4089
- const readState = getReadState(ctx.session);
4090
- const absKey = `${ctx.handle.cwd}::${target}`;
4091
- const prior = readState?.get(absKey);
4092
- if (!prior) return `multi_edit error: ${target} has not been read in this session. Call read_file first so the edits apply against the current contents.`;
4093
- if (prior.contentHash !== hashContent(current)) return `multi_edit error: ${target} has changed on disk since the last read. Re-read the file before editing.`;
4234
+ if (ctx.behavior?.requireReadBeforeEdit) {
4235
+ const readState = resolveReadStateMap(ctx);
4236
+ if (readState) {
4237
+ const absKey = readStateKey(ctx.handle.cwd, target);
4238
+ const prior = readState.get(absKey);
4239
+ if (!prior) return `multi_edit error: ${target} has not been read in this session. Call read_file first so the edits apply against the current contents. (Reads inside a \`spawn\` subagent with \`persist: false\` and without \`shareReadState: true\` do NOT propagate to the parent — re-read in the calling context.)`;
4240
+ if (prior.contentHash !== hashContent(current)) return `multi_edit error: ${target} has changed on disk since the last read. Re-read the file before editing.`;
4241
+ }
4094
4242
  }
4095
4243
  let totalReplacements = 0;
4096
4244
  for (let i = 0; i < steps.length; i++) {
@@ -4110,11 +4258,11 @@ const multiEdit = {
4110
4258
  totalReplacements += occurrences;
4111
4259
  }
4112
4260
  await ctx.execution.writeFile(ctx.handle, target, current);
4113
- if (ctx.session) {
4114
- const readState = getReadState(ctx.session);
4115
- const absKey = `${ctx.handle.cwd}::${target}`;
4116
- const prior = readState?.get(absKey);
4117
- if (readState && prior) readState.set(absKey, {
4261
+ const readState = resolveReadStateMap(ctx);
4262
+ if (readState) {
4263
+ const absKey = readStateKey(ctx.handle.cwd, target);
4264
+ const prior = readState.get(absKey);
4265
+ if (prior) readState.set(absKey, {
4118
4266
  ...prior,
4119
4267
  contentHash: hashContent(current),
4120
4268
  mtimeMs: Date.now()
@@ -4207,6 +4355,7 @@ const DEFAULT_BYTE_CAP = 262144;
4207
4355
  */
4208
4356
  const DEFAULT_IMAGE_BYTE_CAP = 5 * 1024 * 1024;
4209
4357
  const readFile$1 = {
4358
+ isConcurrencySafe: true,
4210
4359
  spec: {
4211
4360
  name: "read_file",
4212
4361
  description: "Read a file by path. Returns lines [offset..offset+limit). Default offset=1, limit=2000. Each line is prefixed with its 1-indexed line number followed by a tab (e.g. `42\\tconst foo = bar`); the prefix is metadata, not part of the file. Mirrors Claude Code's `cat -n`-style compact output for token efficiency. A trailing footer explains how to read the rest when truncated. Binary files return a short marker rather than mojibake.",
@@ -4264,14 +4413,16 @@ const readFile$1 = {
4264
4413
  return `File not found: ${path}.${await suggestionFor(ctx.execution, ctx.handle, path)}`;
4265
4414
  }
4266
4415
  const totalBytes = Buffer.byteLength(raw);
4267
- const readState = ctx.behavior?.dedupReads !== false ? getReadState(ctx.session) : void 0;
4268
- const absKey = `${ctx.handle.cwd}::${path}`;
4416
+ const dedupEnabled = ctx.behavior?.dedupReads !== false;
4417
+ const gateEnabled = ctx.behavior?.requireReadBeforeEdit === true;
4418
+ const readState = dedupEnabled || gateEnabled ? resolveReadStateMap(ctx) : void 0;
4419
+ const absKey = readStateKey(ctx.handle.cwd, path);
4269
4420
  const offsetForKey = normalizeInteger(offset, 1);
4270
4421
  const limitForKey = normalizeInteger(limit, DEFAULT_LINE_LIMIT);
4271
4422
  const maxBytesForKey = normalizeInteger(maxBytes, DEFAULT_BYTE_CAP);
4272
4423
  const showLineNumbers = typeof lineNumbers === "boolean" ? lineNumbers : ctx.behavior?.readLineNumbers ?? true;
4273
4424
  const currentHash = readState ? hashContent(raw) : "";
4274
- if (readState) {
4425
+ if (dedupEnabled && readState) {
4275
4426
  const prior = readState.get(absKey);
4276
4427
  if (prior && prior.contentHash === currentHash && prior.offset === offsetForKey && prior.limit === limitForKey && prior.maxBytes === maxBytesForKey && prior.lineNumbers === showLineNumbers) return `File ${path} unchanged since the previous read in this session — the prior result is still current.`;
4277
4428
  }
@@ -4399,7 +4550,110 @@ function extractTrailingCommand(command) {
4399
4550
  * context's own default (30 s for in-process).
4400
4551
  */
4401
4552
  const DEFAULT_MAX_OUTPUT_BYTES = 32768;
4553
+ /**
4554
+ * Best-effort read-only allow-list for the leading command token. Members
4555
+ * are commands whose stock behavior cannot mutate the workspace under any
4556
+ * argument combination — `ls`, `cat`, `pwd`, etc. Commands that *can*
4557
+ * mutate depending on flags (`find -delete`, `git tag <name>`, `tar -x`)
4558
+ * are intentionally excluded; the input-aware {@link isReadOnlyShellCommand}
4559
+ * predicate falls back to the conservative "not safe" answer for them, so
4560
+ * the scheduler barriers them.
4561
+ */
4562
+ const SHELL_READ_ONLY_COMMANDS = new Set([
4563
+ "ls",
4564
+ "cat",
4565
+ "head",
4566
+ "tail",
4567
+ "wc",
4568
+ "pwd",
4569
+ "whoami",
4570
+ "id",
4571
+ "date",
4572
+ "uname",
4573
+ "hostname",
4574
+ "tty",
4575
+ "echo",
4576
+ "printf",
4577
+ "env",
4578
+ "printenv",
4579
+ "which",
4580
+ "type",
4581
+ "command",
4582
+ "file",
4583
+ "stat",
4584
+ "grep",
4585
+ "rg",
4586
+ "ag",
4587
+ "true",
4588
+ "false",
4589
+ "test"
4590
+ ]);
4591
+ /**
4592
+ * `git` subcommands that are pure reads regardless of arguments. Excludes
4593
+ * `branch`/`tag`/`remote` (which can mutate when given a name) and
4594
+ * `config` (which writes when given a value).
4595
+ */
4596
+ const GIT_READ_ONLY_SUBCOMMANDS = new Set([
4597
+ "status",
4598
+ "log",
4599
+ "diff",
4600
+ "show",
4601
+ "blame",
4602
+ "rev-parse",
4603
+ "ls-files",
4604
+ "ls-tree",
4605
+ "cat-file",
4606
+ "reflog",
4607
+ "shortlog",
4608
+ "describe",
4609
+ "rev-list",
4610
+ "name-rev",
4611
+ "whatchanged",
4612
+ "merge-base",
4613
+ "symbolic-ref"
4614
+ ]);
4615
+ /**
4616
+ * Conservative read-only verdict for a shell command — used to opt a
4617
+ * `shell` invocation into the scheduler's concurrent fleet. Returns
4618
+ * `false` (fail-closed) on anything ambiguous so the scheduler barriers
4619
+ * it. Specifically:
4620
+ *
4621
+ * - Rejects compound commands (`;`, `&&`, `||`, `|`) and redirects (`>`,
4622
+ * `>>`, `<`) — even a pipe to a read-only sink is treated as too
4623
+ * complex to analyze.
4624
+ * - Rejects subshell / process substitution (`$(...)`, `` `...` ``,
4625
+ * `<(...)`, `>(...)`).
4626
+ * - Skips leading `VAR=value` env assignments to find the real
4627
+ * command token.
4628
+ * - Strips a possible absolute path on the command (`/usr/bin/ls` → `ls`).
4629
+ * - Allows the command iff its base name is in
4630
+ * {@link SHELL_READ_ONLY_COMMANDS} OR it's `git <subcmd>` where
4631
+ * `<subcmd>` is in {@link GIT_READ_ONLY_SUBCOMMANDS}.
4632
+ *
4633
+ * Cheap (no spawned process; regex + token scan). Safe to call from the
4634
+ * hot scheduler path.
4635
+ */
4636
+ function isReadOnlyShellCommand(command) {
4637
+ if (typeof command !== "string") return false;
4638
+ const trimmed = command.trim();
4639
+ if (trimmed === "") return false;
4640
+ if (/[<>;&|`\n]/.test(trimmed)) return false;
4641
+ if (trimmed.includes("$(") || trimmed.includes("<(") || trimmed.includes(">(")) return false;
4642
+ const tokens = trimmed.split(/\s+/);
4643
+ let i = 0;
4644
+ while (i < tokens.length && /^[A-Z_]\w*=/i.test(tokens[i])) i++;
4645
+ const head = tokens[i];
4646
+ if (!head) return false;
4647
+ const base = head.split("/").pop() ?? head;
4648
+ if (SHELL_READ_ONLY_COMMANDS.has(base)) return true;
4649
+ if (base === "git") {
4650
+ const sub = tokens[i + 1];
4651
+ return typeof sub === "string" && GIT_READ_ONLY_SUBCOMMANDS.has(sub);
4652
+ }
4653
+ return false;
4654
+ }
4402
4655
  const shell = {
4656
+ isConcurrencySafe: (input) => isReadOnlyShellCommand(input.command),
4403
4657
  spec: {
4404
4658
  name: "shell",
4405
4659
  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.",
@@ -4676,6 +4930,7 @@ function createSpawnTool(options = {}) {
4676
4930
  get totalChildStats() {
4677
4931
  return { ...localStats };
4678
4932
  },
4933
+ isConcurrencySafe: true,
4679
4934
  spec: {
4680
4935
  name: "spawn",
4681
4936
  description: "Spawn a sub-agent for a self-contained task that benefits from isolation (separate context window, separate retries) — for example, a deep research dive or a long codegen pass on a specific file. The sub-agent runs independently with its own tool access and returns its final response. Do NOT spawn for sequential steps you could do yourself.",
@@ -4717,18 +4972,23 @@ function createSpawnTool(options = {}) {
4717
4972
  let result = "";
4718
4973
  let unbubble;
4719
4974
  try {
4720
- const agent = createAgent({
4975
+ const parentPreset = {
4721
4976
  ...ctx.name !== void 0 ? { name: ctx.name } : {},
4722
4977
  ...ctx.system !== void 0 ? { system: ctx.system } : {},
4723
4978
  tools: ctx.tools,
4724
4979
  ...ctx.toolAliases !== void 0 ? { toolAliases: ctx.toolAliases } : {},
4725
4980
  ...ctx.mcpServers !== void 0 ? { mcpServers: ctx.mcpServers } : {},
4726
4981
  ...ctx.skills !== void 0 ? { skills: ctx.skills } : {},
4727
- ...ctx.behavior !== void 0 ? { behavior: ctx.behavior } : {},
4982
+ ...ctx.behavior !== void 0 ? { behavior: ctx.behavior } : {}
4983
+ };
4984
+ const sharedReadState = options.shareReadState ? resolveReadStateMap(ctx) : void 0;
4985
+ const agent = createAgent({
4986
+ ...parentPreset,
4728
4987
  ...options.preset,
4729
4988
  provider: ctx.provider,
4730
4989
  execution: ctx.execution,
4731
- ...options.persist && ctx.session ? { session: ctx.session } : {}
4990
+ ...options.persist && ctx.session ? { session: ctx.session } : {},
4991
+ ...sharedReadState ? { readState: sharedReadState } : {}
4732
4992
  });
4733
4993
  if (forwardHooks) {
4734
4994
  const unregisterEnricher = agent.hooks.hook("tool:before", async (toolCtx) => {
@@ -4903,6 +5163,6 @@ const writeFile$1 = {
4903
5163
  }
4904
5164
  };
4905
5165
  //#endregion
4906
- export { 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, 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, TOOL_USE_AFTER_ERROR_MESSAGE as y };
5166
+ 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 };
4907
5167
 
4908
- //# sourceMappingURL=tools-CWEDS2ZT.js.map
5168
+ //# sourceMappingURL=tools-DKdyPoUf.js.map