@sema-agent/core 2.3.0 → 2.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 (83) hide show
  1. package/dist/agents/send-message-tool.d.ts +4 -0
  2. package/dist/agents/send-message-tool.js +37 -24
  3. package/dist/agents/subagent.js +279 -128
  4. package/dist/brain/errors.d.ts +1 -0
  5. package/dist/brain/errors.js +14 -0
  6. package/dist/brain/stream-engine.js +3 -3
  7. package/dist/core/auto-compaction.d.ts +2 -0
  8. package/dist/core/auto-compaction.js +2 -1
  9. package/dist/core/context-edit.js +2 -1
  10. package/dist/core/mcp.js +4 -1
  11. package/dist/core/runner/prepare-task.d.ts +5 -0
  12. package/dist/core/runner/prepare-task.js +41 -2
  13. package/dist/core/runner/runtask.js +39 -9
  14. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  15. package/dist/core/runner/tool-disclosure.js +16 -5
  16. package/dist/core/runner/tool-output-projection.js +2 -1
  17. package/dist/core/session-reconcile.d.ts +1 -0
  18. package/dist/core/session-reconcile.js +40 -20
  19. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +37 -0
  20. package/dist/core/store-contracts/checkpoint-store-contract.js +195 -0
  21. package/dist/core/store-contracts/contract-harness.d.ts +6 -0
  22. package/dist/core/store-contracts/contract-harness.js +16 -0
  23. package/dist/core/store-contracts/contract-kit-version.d.ts +1 -0
  24. package/dist/core/store-contracts/contract-kit-version.js +2 -0
  25. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -0
  26. package/dist/core/store-contracts/file-snapshot-store-contract.js +126 -0
  27. package/dist/core/store-contracts/mailbox-store-contract.d.ts +6 -0
  28. package/dist/core/store-contracts/mailbox-store-contract.js +193 -0
  29. package/dist/core/store-contracts/session-repo-contract.d.ts +3 -0
  30. package/dist/core/store-contracts/session-repo-contract.js +36 -0
  31. package/dist/core/store-contracts/tool-result-store-contract.d.ts +3 -0
  32. package/dist/core/store-contracts/tool-result-store-contract.js +35 -0
  33. package/dist/core/task-notification.d.ts +2 -0
  34. package/dist/core/task-registry-agent.d.ts +6 -0
  35. package/dist/core/task-registry-agent.js +24 -1
  36. package/dist/core/task-registry-monitor.js +6 -6
  37. package/dist/core/task-registry-shared.d.ts +9 -2
  38. package/dist/core/task-registry-shared.js +1 -1
  39. package/dist/core/task-registry.d.ts +8 -0
  40. package/dist/core/task-registry.js +59 -4
  41. package/dist/core/tool-result-store.d.ts +3 -2
  42. package/dist/core/tool-result-store.js +12 -4
  43. package/dist/core/trace.d.ts +7 -0
  44. package/dist/core/types.d.ts +10 -3
  45. package/dist/engine/compaction/compaction.d.ts +5 -0
  46. package/dist/engine/compaction/compaction.js +68 -2
  47. package/dist/engine/compaction/utils.d.ts +6 -0
  48. package/dist/engine/compaction/utils.js +53 -3
  49. package/dist/engine/harness/messages.d.ts +1 -1
  50. package/dist/engine/harness/messages.js +11 -3
  51. package/dist/engine/loop/types.d.ts +2 -0
  52. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  53. package/dist/engine/lsp/node-lsp-manager.js +16 -0
  54. package/dist/engine/session/import-validate.js +30 -1
  55. package/dist/engine/session/session.js +7 -5
  56. package/dist/index.d.ts +8 -0
  57. package/dist/index.js +8 -0
  58. package/dist/internal/harness.d.ts +1 -1
  59. package/dist/internal/harness.js +1 -1
  60. package/dist/orchestration/builtin-workflows.d.ts +1 -1
  61. package/dist/orchestration/builtin-workflows.js +11 -2
  62. package/dist/orchestration/workflow-governance.d.ts +6 -1
  63. package/dist/orchestration/workflow-governance.js +24 -4
  64. package/dist/orchestration/workflow-primitives.js +7 -1
  65. package/dist/orchestration/workflow-types.d.ts +1 -0
  66. package/dist/orchestration/workflow.d.ts +1 -0
  67. package/dist/orchestration/workflow.js +41 -3
  68. package/dist/tools/fs/fs-bash.d.ts +7 -1
  69. package/dist/tools/fs/fs-bash.js +59 -22
  70. package/dist/tools/fs/fs-read.js +22 -11
  71. package/dist/tools/fs/fs-search-tools.js +3 -3
  72. package/dist/tools/fs/fs-shared.d.ts +20 -7
  73. package/dist/tools/fs/fs-shared.js +17 -3
  74. package/dist/tools/fs/fs-write.js +4 -4
  75. package/dist/tools/fs/index.d.ts +2 -0
  76. package/dist/tools/fs/index.js +7 -1
  77. package/dist/tools/fs/repo-map.js +2 -2
  78. package/dist/tools/fs/safety.d.ts +10 -0
  79. package/dist/tools/fs/safety.js +15 -1
  80. package/dist/tools/monitor.js +18 -4
  81. package/dist/tools/web.js +6 -2
  82. package/dist/tools/worktree.js +46 -25
  83. package/package.json +1 -1
@@ -104,6 +104,21 @@ export function workflowAgentCallKey(ordinal, spec, opts) {
104
104
  };
105
105
  return `${ordinal}:${boundInputHashOf(identity)}`;
106
106
  }
107
+ function resolveChildSessionIdAtSpawn(spec) {
108
+ if (spec.sessionId)
109
+ return spec.sessionId;
110
+ if (spec.requireExistingSession === true || spec.resumeAt !== undefined)
111
+ return undefined;
112
+ return randomUUID();
113
+ }
114
+ export function assertSupportedAgentIsolation(isolation) {
115
+ if (isolation !== undefined && isolation !== "worktree") {
116
+ const shown = typeof isolation === "string" ? JSON.stringify(isolation) : String(isolation);
117
+ const e = new Error(`isolation ${shown} is not supported in workflow agents — only "worktree" (omit the option to run in the shared working tree). The agent was not started (fail-closed: an unrecognized isolation value must never silently run in the shared working tree).`);
118
+ e.code = "isolation.invalid";
119
+ throw e;
120
+ }
121
+ }
107
122
  function rethrowIfMaxAgents(e) {
108
123
  if (e instanceof WorkflowMaxAgentsError)
109
124
  throw e;
@@ -626,6 +641,14 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
626
641
  };
627
642
  return { tail, onActivity };
628
643
  };
644
+ const createWorkspaceObserver = (rec) => (workspace) => {
645
+ if (finalized)
646
+ return;
647
+ if (!workspace.isolated || rec.worktreeDir === workspace.cwd)
648
+ return;
649
+ rec.worktreeDir = workspace.cwd;
650
+ void persist("update");
651
+ };
629
652
  let currentPhase;
630
653
  let openMarkerPhase;
631
654
  let currentGroup;
@@ -700,6 +723,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
700
723
  if ("error" in compiled)
701
724
  throw new TypeError(`agent({schema}) received an invalid JSON Schema: ${compiled.error}`);
702
725
  }
726
+ assertSupportedAgentIsolation(agentOpts.isolation);
703
727
  const label = agentOpts.label ?? `agent-${run.agents.length + 1}`;
704
728
  const phase = agentOpts.phase ?? currentPhase?.title;
705
729
  const phaseInstance = resolveAgentPhase(agentOpts.phase);
@@ -799,7 +823,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
799
823
  opts.onForwardEvent(e.type === "task_progress" ? { ...e, workflowRunId: runId, workflowAgentLabel: label } : e);
800
824
  }
801
825
  : undefined;
802
- const baseInternals = { ...(agentOpts.isolation ? { isolation: agentOpts.isolation } : {}), ...(opts.parentCwd !== undefined ? { parentCwd: opts.parentCwd } : {}), ...spawnAttribution, ...(enrichedForward !== undefined ? { onForwardEvent: enrichedForward } : {}), agentName: label };
826
+ const baseInternals = { ...(agentOpts.isolation ? { isolation: agentOpts.isolation } : {}), ...(opts.parentCwd !== undefined ? { parentCwd: opts.parentCwd } : {}), ...spawnAttribution, ...(enrichedForward !== undefined ? { onForwardEvent: enrichedForward } : {}), agentName: label, onWorkspaceResolved: createWorkspaceObserver(rec) };
803
827
  let attempts = 0;
804
828
  let throttleRetried = false;
805
829
  let lastAttemptReason;
@@ -828,7 +852,12 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
828
852
  const onCallerAbort = () => attemptCtl.abort(new Error("workflow aborted"));
829
853
  effectiveSignal?.addEventListener("abort", onCallerAbort, { once: true });
830
854
  armWatchdog();
831
- const attemptSpec = { ...runSpec, signal: attemptCtl.signal };
855
+ const attemptSessionId = resolveChildSessionIdAtSpawn(runSpec);
856
+ const attemptSpec = attemptSessionId !== undefined ? { ...runSpec, sessionId: attemptSessionId, signal: attemptCtl.signal } : { ...runSpec, signal: attemptCtl.signal };
857
+ if (attemptSessionId !== undefined && !finalized) {
858
+ rec.sessionId = attemptSessionId;
859
+ void persist("update");
860
+ }
832
861
  const attemptInternals = {
833
862
  ...baseInternals,
834
863
  ...(bceSink !== undefined
@@ -1071,6 +1100,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1071
1100
  if ("error" in compiled)
1072
1101
  throw new TypeError(`agent({schema}) received an invalid JSON Schema: ${compiled.error}`);
1073
1102
  }
1103
+ assertSupportedAgentIsolation(agentOpts.isolation);
1074
1104
  const label = agentOpts.label ?? `agent-${run.agents.length + 1}`;
1075
1105
  const phase = agentOpts.phase ?? currentPhase?.title;
1076
1106
  const phaseInstance = resolveAgentPhase(agentOpts.phase);
@@ -1115,14 +1145,17 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1115
1145
  rec.startedAt = now();
1116
1146
  bceSpawn(callKey, label, agentOpts.agentType, false);
1117
1147
  let stream;
1148
+ let childSessionId;
1118
1149
  try {
1119
1150
  const typedSpec0 = applyWorkflowAgentType(spec, agentOpts.agentType, agentRegistry);
1120
1151
  const typedSpec = typedSpec0.model === undefined && inheritedModelSnap !== undefined ? { ...typedSpec0, model: inheritedModelSnap } : typedSpec0;
1121
1152
  const framedSpec = withWorkflowChildPersona(typedSpec, agentOpts.schema ?? typedSpec.outputSchema);
1122
1153
  const authInherit = framedSpec.getApiKeyAndHeaders === undefined && opts.defaultGetApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: opts.defaultGetApiKeyAndHeaders } : {};
1123
- const runSpec = agentOpts.schema
1154
+ const baseRunSpec = agentOpts.schema
1124
1155
  ? { ...framedSpec, ...authInherit, signal: effectiveSignal, outputSchema: agentOpts.schema }
1125
1156
  : { ...framedSpec, ...authInherit, signal: effectiveSignal };
1157
+ childSessionId = resolveChildSessionIdAtSpawn(baseRunSpec);
1158
+ const runSpec = childSessionId !== undefined ? { ...baseRunSpec, sessionId: childSessionId } : baseRunSpec;
1126
1159
  const enrichedForwardS = opts.onForwardEvent !== undefined
1127
1160
  ? (e) => {
1128
1161
  opts.onForwardEvent(e.type === "task_progress" ? { ...e, workflowRunId: runId, workflowAgentLabel: label } : e);
@@ -1135,6 +1168,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1135
1168
  ...(enrichedForwardS !== undefined ? { onForwardEvent: enrichedForwardS } : {}),
1136
1169
  agentName: label,
1137
1170
  onActivity,
1171
+ onWorkspaceResolved: createWorkspaceObserver(rec),
1138
1172
  ...(bceSink !== undefined
1139
1173
  ? {
1140
1174
  onForwardEvent: (e) => {
@@ -1157,6 +1191,10 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1157
1191
  bceTerminal(callKey, "failed", err instanceof Error ? err.message : String(err), undefined, undefined);
1158
1192
  throw err;
1159
1193
  }
1194
+ if (childSessionId !== undefined && !finalized) {
1195
+ rec.sessionId = childSessionId;
1196
+ void persist("update");
1197
+ }
1160
1198
  const steer = async (content) => {
1161
1199
  const marker = `steer-${++steerMarkerSeq}`;
1162
1200
  const framed = `[operator steer ${marker}] An operator/leader sent guidance for your task. Take it into account on your NEXT step. ` +
@@ -26,7 +26,13 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
26
26
  execClamp?: ExecClampOption;
27
27
  autoBackgroundOnTimeout?: boolean;
28
28
  oneShot?: boolean;
29
+ additionalRoots?: readonly string[];
30
+ bashDefaultTimeoutMs?: number;
31
+ bashMaxTimeoutMs?: number;
32
+ }): AgentTool;
33
+ export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, execClamp?: ExecClampOption, timeoutOpts?: {
34
+ bashDefaultTimeoutMs?: number;
35
+ bashMaxTimeoutMs?: number;
29
36
  }): AgentTool;
30
- export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, execClamp?: ExecClampOption): AgentTool;
31
37
  export declare function createEnvTaskOutputTool(env: ExecutionEnv): AgentTool;
32
38
  export declare function createEnvTaskStopTool(env: ExecutionEnv, registry?: TaskRegistry): AgentTool;
@@ -5,9 +5,9 @@ import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_ALIASES, TASK_S
5
5
  import { hasBackgroundShell } from "../../core/background-shell.js";
6
6
  import { delimitUntrusted } from "../../core/untrusted-text.js";
7
7
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
8
- import { imageMagicMatches } from "./safety.js";
8
+ import { imageMagicMatches, withinAnyRoot } from "./safety.js";
9
9
  import { ghRateLimitHint } from "./gh-rate-limit.js";
10
- import { BASH_DEFAULT_TIMEOUT_SEC, BASH_MAX_TIMEOUT_SEC, BASH_DEFAULT_TIMEOUT_MS, BASH_MAX_TIMEOUT_MS, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, shellRecoveryHint, CWD_SENTINEL, msTimeoutToSec, } from "./fs-shared.js";
10
+ import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, shellRecoveryHint, CWD_SENTINEL, } from "./fs-shared.js";
11
11
  import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly } from "./bash-readonly-classifier.js";
12
12
  export function bashReversibilityProbe(allow) {
13
13
  const allowSet = new Set(allow ?? BASH_READONLY_DEFAULT_ALLOW);
@@ -156,9 +156,15 @@ export function canAutoBackground(command) {
156
156
  return false;
157
157
  return true;
158
158
  }
159
- async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef, detach, execClamp, toolCallId, readOnly) {
160
- let timeout = Math.min(BASH_MAX_TIMEOUT_SEC, Math.max(1, Math.floor(timeoutSec ?? BASH_DEFAULT_TIMEOUT_SEC)));
161
- const requestedSec = timeout;
159
+ function msTimeoutToRequestedSec(timeoutMs) {
160
+ if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
161
+ return undefined;
162
+ return Math.max(1, Math.round(timeoutMs / 1000));
163
+ }
164
+ async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, cwdRef, detach, execClamp, toolCallId, readOnly, containmentRoots) {
165
+ const requestedSec = Math.max(1, Math.floor(timeoutSec ?? caps.defaultSec));
166
+ let timeout = Math.min(caps.maxSec, requestedSec);
167
+ const cappedByMaxTimeout = requestedSec > caps.maxSec;
162
168
  let clampedByDeadline = false;
163
169
  let deadlineExhausted = false;
164
170
  if (execClamp) {
@@ -167,7 +173,7 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
167
173
  const rawRemainSec = Math.floor((deadline - Date.now()) / 1000);
168
174
  const maxSec = Math.max(1, rawRemainSec);
169
175
  if (maxSec < timeout) {
170
- execClamp.onClamp?.(timeout, maxSec);
176
+ execClamp.onClamp?.(requestedSec, maxSec);
171
177
  timeout = maxSec;
172
178
  clampedByDeadline = true;
173
179
  deadlineExhausted = rawRemainSec < 1;
@@ -235,13 +241,16 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
235
241
  ? deadlineExhausted
236
242
  ? `Command was cut off after ${timeout}s — NOT its requested ${requestedSec}s timeout: the task's wall-clock deadline has already been reached, so the command only got a ${timeout}s grace window. The process was killed; output produced before the cutoff is shown below. Do NOT retry this foreground command — there is no time left. Use run_in_background (exempt from this clamp) if the work must continue, or write out your results now.`
237
243
  : `Command was cut off after ${timeout}s — NOT its requested ${requestedSec}s timeout: the task is near its wall-clock deadline, so the foreground time budget was clamped to the ${timeout}s remaining. The process was killed; output produced before the cutoff is shown below. Do NOT retry this foreground command — the next attempt gets even less time. Use run_in_background (exempt from this clamp) if the work must continue, or write out your results now.`
238
- : `Command timed out after ${timeout}s (timeout limit: ${timeout}s). The process was killed; output produced before the cutoff is shown below. Re-running the same command will likely time out again — consider run_in_background for long commands, a narrower command, or resuming from the partial progress below.`
244
+ : cappedByMaxTimeout
245
+ ? `Command timed out after ${timeout}s — requested ${requestedSec}s, capped at ${caps.maxSec}s (engine ceiling: requests above ${caps.maxSec}s are reduced to it). The process was killed; output produced before the cutoff is shown below. Re-running with a larger timeout gets the same ${caps.maxSec}s ceiling — use run_in_background for work that needs longer, a narrower command, or resume from the partial progress below.`
246
+ : `Command timed out after ${timeout}s (timeout limit: ${timeout}s). The process was killed; output produced before the cutoff is shown below. Re-running the same command will likely time out again — consider run_in_background for long commands, a narrower command, or resuming from the partial progress below.`
239
247
  : res.error.code === "aborted"
240
248
  ? `Command was interrupted (aborted) before completion. Output produced before the interrupt is shown below.`
241
249
  : `Command started but its completion could not be observed — a host-side output-stream callback failed mid-run and the process was terminated (${res.error.message}). Output captured before the cut is shown below; its effects up to that point may have landed. Verify before assuming it needs a full re-run.`;
242
250
  const captured = (pStdout ? `--- partial stdout ---\n${pStdout}` : "") +
243
251
  (pStderr ? `${pStdout ? "\n" : ""}--- partial stderr ---\n${pStderr}` : "");
244
- const body = captured.length > 0
252
+ const zeroOutput = captured.length === 0;
253
+ const body = !zeroOutput
245
254
  ? `\n${delimitUntrusted("partial command output", captured)}`
246
255
  : `\n(no output was produced before the cutoff)`;
247
256
  const overflowNote = cutOverflowFile !== undefined ? shellRecoveryHint(cutOverflowFile, readOnly) : "";
@@ -257,12 +266,15 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
257
266
  ? {
258
267
  timedOut: true,
259
268
  timeoutSec: timeout,
260
- ...(clampedByDeadline ? { requestedTimeoutSec: requestedSec, clampedByDeadline: true } : {}),
269
+ ...(clampedByDeadline || cappedByMaxTimeout ? { requestedTimeoutSec: requestedSec } : {}),
270
+ ...(clampedByDeadline ? { clampedByDeadline: true } : {}),
271
+ ...(cappedByMaxTimeout ? { cappedToMaxTimeoutSec: caps.maxSec } : {}),
261
272
  }
262
273
  : res.error.code === "aborted"
263
274
  ? { aborted: true }
264
275
  : { callbackError: true }),
265
276
  },
277
+ ...(zeroOutput ? { isError: true } : {}),
266
278
  };
267
279
  }
268
280
  return {
@@ -291,6 +303,16 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
291
303
  cwdRef.current = captured;
292
304
  }
293
305
  }
306
+ let cwdOutsideNote = "";
307
+ if (cwdRef && containmentRoots !== undefined && containmentRoots.length > 0 && !withinAnyRoot(containmentRoots, cwdRef.current)) {
308
+ const cwdCanon = await env.canonicalPath(cwdRef.current, signal);
309
+ if (!(cwdCanon.ok && withinAnyRoot(containmentRoots, cwdCanon.value))) {
310
+ cwdOutsideNote = `NOTE: working directory is now outside the task root(s) (${cwdRef.current}); relative paths in the structured file tools (Read/Edit/Write/Grep/Glob) will resolve there and may be refused. Pass absolute in-root paths, or \`cd\` back inside.`;
311
+ }
312
+ }
313
+ const survivedCapNote = cappedByMaxTimeout
314
+ ? `NOTE: requested timeout ${requestedSec}s was capped to the ${caps.maxSec}s engine ceiling (requests above the max are reduced to it) — no effect on this run (the command completed in time); use run_in_background for work that needs longer.`
315
+ : "";
294
316
  const clippedStdout = clipShellOutput(stdout);
295
317
  const clippedStderr = clipShellOutput(stderr);
296
318
  const stdoutImage = dataUriImageFromStdout(stdout);
@@ -300,9 +322,9 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
300
322
  return {
301
323
  content: [
302
324
  { type: "image", data: stdoutImage.data, mimeType: stdoutImage.mime },
303
- { type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}${imgOverflowNote}` },
325
+ { type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}${imgOverflowNote}${cwdOutsideNote ? `\n${cwdOutsideNote}` : ""}${survivedCapNote ? `\n${survivedCapNote}` : ""}` },
304
326
  ],
305
- details: { type: "bash", stdout: "[image data]", stderr: clippedStderr, exitCode, isImage: true, ...(imgOverflowFile !== undefined ? { output_file: imgOverflowFile } : {}) },
327
+ details: { type: "bash", stdout: "[image data]", stderr: clippedStderr, exitCode, isImage: true, ...(imgOverflowFile !== undefined ? { output_file: imgOverflowFile } : {}), ...(cappedByMaxTimeout ? { requestedTimeoutSec: requestedSec, cappedToMaxTimeoutSec: caps.maxSec } : {}) },
306
328
  };
307
329
  }
308
330
  const cleanOverflowFile = stdout.length > bashMaxOutputChars() || stderr.length > bashMaxOutputChars() ? await writeShellOverflowFile(env, stdout, stderr) : undefined;
@@ -313,6 +335,10 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
313
335
  parts.push(`--- stderr ---\n${clippedStderr}`);
314
336
  if (overflowNote)
315
337
  parts.push(overflowNote.trim());
338
+ if (cwdOutsideNote)
339
+ parts.push(cwdOutsideNote);
340
+ if (survivedCapNote)
341
+ parts.push(survivedCapNote);
316
342
  return {
317
343
  content: parts.join("\n"),
318
344
  details: {
@@ -322,10 +348,11 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
322
348
  exitCode,
323
349
  ...(exit1Note ? { returnCodeInterpretation: exit1Note } : {}),
324
350
  ...(cleanOverflowFile !== undefined ? { output_file: cleanOverflowFile } : {}),
351
+ ...(cappedByMaxTimeout ? { requestedTimeoutSec: requestedSec, cappedToMaxTimeoutSec: caps.maxSec } : {}),
325
352
  },
326
353
  };
327
354
  }
328
- function bashDescription(coAuthor, bgNotifies = false, bgRetained = false) {
355
+ function bashDescription(coAuthor, caps, bgNotifies = false, bgRetained = false) {
329
356
  const coAuthorLines = coAuthor === false
330
357
  ? "- Follow the deployment's commit-message conventions."
331
358
  : `- End git commit messages with:\nCo-Authored-By: ${coAuthor}`;
@@ -334,7 +361,7 @@ function bashDescription(coAuthor, bgNotifies = false, bgRetained = false) {
334
361
  - Working directory persists between calls, but prefer absolute paths — a \`cd\` carries over and moves the base for every later command. Shell state (env vars, functions) does not persist; the shell is re-initialized each call, starting at the configured root.
335
362
  - IMPORTANT: Avoid using this tool to run \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user.
336
363
  - File search by name: use the Glob tool (NOT \`find\` or \`ls\`); content search: use the Grep tool (NOT \`grep\`/\`rg\` in the shell).
337
- - \`timeout\` is in milliseconds: default ${BASH_DEFAULT_TIMEOUT_MS}, max ${BASH_MAX_TIMEOUT_MS}.
364
+ - \`timeout\` is in milliseconds: default ${caps.defaultMs}, max ${caps.maxMs}.
338
365
  - \`run_in_background\` runs the command detached: it keeps running across turns and ${bgNotifies ? "re-invokes you when it exits" : "you read its output later with TaskOutput(task_id)"}. No \`&\` needed.${bgRetained ? "" : " Background processes do NOT survive the session — they are reaped when the task ends. A deliverable that must stay alive afterwards (a server, a daemon) needs a self-detaching FOREGROUND start instead: run `nohup cmd >log 2>&1 &` as a normal foreground command (portable; setsid does not exist on macOS), or use a service manager."}
339
366
 
340
367
  # Git
@@ -348,15 +375,17 @@ ${coAuthorLines}
348
375
  - Treat evidence of a deployment restriction — "Operation not permitted", "Read-only file system", a denied path, or a network failure — as a boundary, not a bug: adjust within the allowed scope or report the limit; don't reach for a destructive or privilege-escalating workaround.`;
349
376
  }
350
377
  export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = { current: rootCanonical }, taskOpts = {}) {
378
+ const timeoutCaps = resolveBashTimeoutCaps(taskOpts);
379
+ const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
351
380
  const bgNotifies = taskOpts.taskRegistry !== undefined && taskOpts.onTaskNotification !== undefined;
352
381
  const bgRetained = hasBackgroundShell(env) && env.backgroundCapabilities.retainBackgroundProcesses === true;
353
382
  return defineTool({
354
383
  name: "Bash",
355
384
  contract: { contractId: "core.bash@1", implementationRevision: "1" },
356
- description: bashDescription(coAuthor, bgNotifies, bgRetained),
385
+ description: bashDescription(coAuthor, timeoutCaps, bgNotifies, bgRetained),
357
386
  parameters: Type.Object({
358
387
  command: Type.String({ description: "The command to execute" }),
359
- timeout: Type.Optional(Type.Number({ description: `Optional timeout in milliseconds (max ${BASH_MAX_TIMEOUT_MS})` })),
388
+ timeout: Type.Optional(Type.Number({ description: `Optional timeout in milliseconds (max ${timeoutCaps.maxMs}; requests above the max are capped to it)` })),
360
389
  description: Type.Optional(Type.String({
361
390
  description: 'Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.\n' +
362
391
  "\n" +
@@ -406,7 +435,12 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
406
435
  const appliedTimeoutSec = typeof bgCaps.defaultBgTimeoutSec === "number" && typeof bgCaps.maxBgTimeoutSec === "number"
407
436
  ? Math.min(requestedTimeoutSec ?? bgCaps.defaultBgTimeoutSec, bgCaps.maxBgTimeoutSec)
408
437
  : undefined;
409
- const budgetNote = appliedTimeoutSec !== undefined ? ` Time budget: auto-terminates if still running after ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).` : "";
438
+ const bgCappedByMax = appliedTimeoutSec !== undefined && requestedTimeoutSec !== undefined && typeof bgCaps.maxBgTimeoutSec === "number" && requestedTimeoutSec > bgCaps.maxBgTimeoutSec;
439
+ const budgetNote = appliedTimeoutSec !== undefined
440
+ ? bgCappedByMax
441
+ ? ` Time budget: requested ${requestedTimeoutSec}s, capped at ${bgCaps.maxBgTimeoutSec}s (env ceiling: requests above ${bgCaps.maxBgTimeoutSec}s are reduced to it) — auto-terminates if still running after ${appliedTimeoutSec}s.`
442
+ : ` Time budget: auto-terminates if still running after ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).`
443
+ : "";
410
444
  const lifetimeNote = bgCaps.retainBackgroundProcesses === true
411
445
  ? ""
412
446
  : ` NOTE: background processes do NOT survive the session (reaped at task end). If this is a deliverable service that must stay alive afterwards, host it with a FOREGROUND command instead: \`nohup cmd >log 2>&1 &\` (self-detaching — survives the session).`;
@@ -462,7 +496,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
462
496
  ? ` Output file: ${outputFile} (full output is appended there — Read it any time).`
463
497
  : ` Use TaskOutput("${taskId}") to check interim output.`;
464
498
  if (taskOpts.oneShot === true && onNotify !== undefined) {
465
- return (`Command running in background; task_id=${taskId}.${interimNote} This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput("${taskId}", { block: true }). If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget. TaskStop("${taskId}") to stop.` +
499
+ return (`Command running in background; task_id=${taskId}.${interimNote} This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${taskId}", block: true }). If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget. TaskStop("${taskId}") to stop.` +
466
500
  budgetNote +
467
501
  lifetimeNote);
468
502
  }
@@ -560,7 +594,8 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
560
594
  : `Use TaskOutput("${taskId}") to read its output. `) +
561
595
  `TaskStop("${taskId}") to stop it. ` +
562
596
  (taskOpts.oneShot === true && onNotify !== undefined
563
- ? `This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput("${taskId}", { block: true }).`
597
+ ?
598
+ `This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${taskId}", block: true }).`
564
599
  : onNotify !== undefined
565
600
  ? `You will be notified when it completes — do not poll.`
566
601
  : `Poll TaskOutput until its status is no longer "running".`) +
@@ -574,7 +609,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
574
609
  : undefined;
575
610
  let res;
576
611
  try {
577
- res = await runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, cwdRef, detachChain, taskOpts.execClamp, ctx.toolCallId, false);
612
+ res = await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, cwdRef, detachChain, taskOpts.execClamp, ctx.toolCallId, false, [rootCanonical, ...(taskOpts.additionalRoots ?? [])]);
578
613
  }
579
614
  finally {
580
615
  taskOpts.detachHub?.gc(ctx.toolCallId);
@@ -591,7 +626,9 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
591
626
  },
592
627
  });
593
628
  }
594
- export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
629
+ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, timeoutOpts) {
630
+ const timeoutCaps = resolveBashTimeoutCaps(timeoutOpts);
631
+ const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
595
632
  const sample = [...allow].slice(0, 6).join(", ");
596
633
  return defineTool({
597
634
  name: "Bash",
@@ -601,7 +638,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
601
638
  "allowlisted commands run. Still subject to the deployment's approval policy.",
602
639
  parameters: Type.Object({
603
640
  command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
604
- timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${BASH_DEFAULT_TIMEOUT_MS}, max ${BASH_MAX_TIMEOUT_MS}).` })),
641
+ timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${timeoutCaps.defaultMs}, max ${timeoutCaps.maxMs}; requests above the max are capped to it).` })),
605
642
  }),
606
643
  effect: "read",
607
644
  execute: async (args, ctx) => {
@@ -609,7 +646,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
609
646
  const reason = coarseReadonlyCheck(command, allow);
610
647
  if (reason)
611
648
  return errorResult(`Error (Bash): ${reason}`);
612
- return runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
649
+ return runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
613
650
  },
614
651
  });
615
652
  }
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { sha256, resolveKey, violationText, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
3
+ import { sha256, resolveKey, violationText, violationDetails, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
4
4
  import { decodeTextBytes } from "./encoding.js";
5
5
  import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
6
6
  import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE } from "../../core/mcp.js";
@@ -38,8 +38,8 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
38
38
  "- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.",
39
39
  parameters: Type.Object({
40
40
  ...FILE_PATH_PARAMS,
41
- offset: Type.Optional(Type.Number({ description: "The line number to start reading from. Only provide if the file is too large to read at once (1-based; default 1)." })),
42
- limit: Type.Optional(Type.Number({ description: "The number of lines to read. Only provide if the file is too large to read at once. (Default: the whole file, bounded by the output-token cap.)" })),
41
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "The line number to start reading from. Only provide if the file is too large to read at once (1-based; default 1)." })),
42
+ limit: Type.Optional(Type.Integer({ minimum: 1, description: "The number of lines to read. Only provide if the file is too large to read at once. (Default: the whole file, bounded by the output-token cap.)" })),
43
43
  pages: Type.Optional(Type.String({ description: `Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files. Maximum ${PDF_MAX_PAGES_PER_READ} pages per request.` })),
44
44
  }),
45
45
  effect: "read",
@@ -48,9 +48,13 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
48
48
  const path = fileArgPath(args);
49
49
  if (path === undefined)
50
50
  return errorResult(`Error (Read): file_path is required.`);
51
+ const fileUnchangedResult = (text, reason, startLine, endLine, totalLines) => ({
52
+ content: text,
53
+ details: { type: "file_unchanged", reason, file: { filePath: path, startLine, endLine, totalLines } },
54
+ });
51
55
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, bgOutputReadExemption === undefined ? undefined : (key) => bgOutputReadExemption(key, ctx));
52
56
  if (!r.ok)
53
- return errorResult(violationText("Read", r.violation));
57
+ return errorResult(violationText("Read", r.violation), violationDetails(r.violation));
54
58
  const isNb = isNotebookPath(r.key);
55
59
  const imageMime = imageMimeForRead(r.key);
56
60
  if (imageMime !== undefined) {
@@ -116,20 +120,23 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
116
120
  if (r.key.toLowerCase().endsWith(".pdf")) {
117
121
  return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, undefined, pdfCapabilities));
118
122
  }
119
- if (hasBinaryExtension(r.key)) {
120
- return errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`);
121
- }
123
+ const binaryExt = hasBinaryExtension(r.key);
124
+ const binaryExtRefusal = () => errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`);
122
125
  const info = await env.fileInfo(r.key, ctx.signal);
123
126
  if (info.ok) {
124
127
  if (info.value.kind === "directory") {
125
128
  return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
126
129
  }
127
130
  if (info.value.size > SLICED_READ_MAX_BYTES) {
131
+ if (binaryExt)
132
+ return binaryExtRefusal();
128
133
  return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
129
134
  `(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
130
135
  `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
131
136
  }
132
137
  if (!isNb && info.value.size > MAX_READ_BYTES && limit === undefined) {
138
+ if (binaryExt)
139
+ return binaryExtRefusal();
133
140
  return errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
134
141
  }
135
142
  }
@@ -143,11 +150,15 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
143
150
  return errorResult(`Error (Read): cannot read "${path}": ${readBin.error.message}`);
144
151
  const readSize = readBin.value.byteLength;
145
152
  if (readSize > SLICED_READ_MAX_BYTES) {
153
+ if (binaryExt)
154
+ return binaryExtRefusal();
146
155
  return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
147
156
  `(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
148
157
  `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
149
158
  }
150
159
  if (!isNb && readSize > MAX_READ_BYTES && limit === undefined) {
160
+ if (binaryExt)
161
+ return binaryExtRefusal();
151
162
  return errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
152
163
  }
153
164
  if (pdfMagicMatches(readBin.value)) {
@@ -194,10 +205,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
194
205
  }
195
206
  const prevNb = state.get(r.key);
196
207
  if (prevNb?.seededFromContext && !prevNb.isPartialView && prevNb.hash === hash) {
197
- return seededFileUnchangedReminder(r.key);
208
+ return fileUnchangedResult(seededFileUnchangedReminder(r.key), "already-in-context", 1, total, total);
198
209
  }
199
210
  if (prevNb && !prevNb.isPartialView && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
200
- return `[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`;
211
+ return fileUnchangedResult(`[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`, "unchanged-since-last-read", 1, total, total);
201
212
  }
202
213
  state.set(r.key, { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total }, lastReadAt: Date.now() });
203
214
  const bodyBlocks = rendered.blocks.length > 0 ? rendered.blocks : [{ type: "text", text: "[notebook has 0 cells]" }];
@@ -251,10 +262,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
251
262
  const truncated = start > 1 || end < total || pageMarker !== undefined;
252
263
  const prev = state.get(r.key);
253
264
  if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
254
- return seededFileUnchangedReminder(r.key);
265
+ return fileUnchangedResult(seededFileUnchangedReminder(r.key), "already-in-context", 1, total, total);
255
266
  }
256
267
  if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
257
- return `[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`;
268
+ return fileUnchangedResult(`[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`, "unchanged-since-last-read", start, end, total);
258
269
  }
259
270
  state.set(r.key, {
260
271
  hash,
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { resolveKey, violationText } from "./safety.js";
3
+ import { resolveKey, violationText, violationDetails } from "./safety.js";
4
4
  import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, invalidGlobTokens } from "./search.js";
5
5
  export function createGrepTool(env, rootCanonical, additionalRoots) {
6
6
  return defineTool({
@@ -63,7 +63,7 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
63
63
  if (a.path !== undefined) {
64
64
  const r = await resolveKey(env, rootCanonical, a.path, ctx.signal, rootCanonical, additionalRoots);
65
65
  if (!r.ok)
66
- return errorResult(violationText("Grep", r.violation));
66
+ return errorResult(violationText("Grep", r.violation), violationDetails(r.violation));
67
67
  scoped = r.key;
68
68
  }
69
69
  const grepRun = await runGrepDetailed(env, rootCanonical, {
@@ -182,7 +182,7 @@ export function createGlobTool(env, rootCanonical, additionalRoots) {
182
182
  if (path !== undefined) {
183
183
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, rootCanonical, additionalRoots);
184
184
  if (!r.ok)
185
- return errorResult(violationText("Glob", r.violation));
185
+ return errorResult(violationText("Glob", r.violation), violationDetails(r.violation));
186
186
  scoped = r.key;
187
187
  }
188
188
  const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results }, ctx.signal);
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import type { ExecutionEnv } from "../../internal/harness-types.js";
3
- import { type ReadFileState } from "./safety.js";
3
+ import { type FsViolation, type ReadFileState } from "./safety.js";
4
4
  import { type DecodedTextFile } from "./encoding.js";
5
5
  import { type ImageDownsampler } from "../../core/mcp.js";
6
6
  export declare const MAX_READ_BYTES: number;
@@ -15,11 +15,7 @@ export declare function decodeEditBytes(bytes: Uint8Array, path: string): {
15
15
  message: string;
16
16
  };
17
17
  export declare function persistedTextOf(encoded: string | Uint8Array): string;
18
- export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: {
19
- code: string;
20
- message: string;
21
- partialView?: boolean;
22
- }, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
18
+ export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: Pick<FsViolation, "code" | "message" | "partialView">, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
23
19
  export declare const MAX_IMAGE_READ_BYTES: number;
24
20
  export declare const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES: number;
25
21
  export type ReadImageDownsamplerOption = ImageDownsampler | false | undefined;
@@ -30,6 +26,20 @@ export declare const BASH_DEFAULT_TIMEOUT_SEC = 120;
30
26
  export declare const BASH_MAX_TIMEOUT_SEC = 600;
31
27
  export declare const BASH_DEFAULT_TIMEOUT_MS: number;
32
28
  export declare const BASH_MAX_TIMEOUT_MS: number;
29
+ export declare function resolveBashTimeoutCaps(opts?: {
30
+ bashDefaultTimeoutMs?: number;
31
+ bashMaxTimeoutMs?: number;
32
+ }): {
33
+ defaultMs: number;
34
+ maxMs: number;
35
+ };
36
+ export declare function bashTimeoutCapsSec(caps: {
37
+ defaultMs: number;
38
+ maxMs: number;
39
+ }): {
40
+ defaultSec: number;
41
+ maxSec: number;
42
+ };
33
43
  export declare function bashMaxOutputChars(): number;
34
44
  export declare const FILE_PATH_PARAMS: {
35
45
  file_path: Type.TOptional<Type.TString>;
@@ -40,7 +50,10 @@ export declare function writeShellOverflowFile(env: ExecutionEnv, stdout: string
40
50
  export declare function shellRecoveryHint(path: string, readOnly: boolean | undefined): string;
41
51
  export declare const FILE_STATE_TRAILER = " (file state is current in your context \u2014 no need to Read it back)";
42
52
  export declare const CWD_SENTINEL = "__cc_cwd_9f2c1b__";
43
- export declare function msTimeoutToSec(timeoutMs: number | undefined): number;
53
+ export declare function msTimeoutToSec(timeoutMs: number | undefined, caps?: {
54
+ defaultMs: number;
55
+ maxMs: number;
56
+ }): number;
44
57
  export declare function ipynbRedirect(toolName: string, path: string): string | undefined;
45
58
  export declare function countLines(s: string): number;
46
59
  export declare function seededFileUnchangedReminder(filePath: string): string;
@@ -51,6 +51,19 @@ export const BASH_DEFAULT_TIMEOUT_SEC = 120;
51
51
  export const BASH_MAX_TIMEOUT_SEC = 600;
52
52
  export const BASH_DEFAULT_TIMEOUT_MS = BASH_DEFAULT_TIMEOUT_SEC * 1000;
53
53
  export const BASH_MAX_TIMEOUT_MS = BASH_MAX_TIMEOUT_SEC * 1000;
54
+ function validTimeoutMs(n) {
55
+ return n !== undefined && Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
56
+ }
57
+ export function resolveBashTimeoutCaps(opts) {
58
+ const defaultMs = validTimeoutMs(opts?.bashDefaultTimeoutMs) ??
59
+ validTimeoutMs(Number(process.env.BASH_DEFAULT_TIMEOUT_MS)) ??
60
+ BASH_DEFAULT_TIMEOUT_MS;
61
+ const maxMs = Math.max(validTimeoutMs(opts?.bashMaxTimeoutMs) ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS)) ?? BASH_MAX_TIMEOUT_MS, defaultMs);
62
+ return { defaultMs, maxMs };
63
+ }
64
+ export function bashTimeoutCapsSec(caps) {
65
+ return { defaultSec: Math.max(1, Math.round(caps.defaultMs / 1000)), maxSec: Math.max(1, Math.round(caps.maxMs / 1000)) };
66
+ }
54
67
  const BASH_DEFAULT_MAX_OUTPUT_CHARS = 30_000;
55
68
  const BASH_MAX_OUTPUT_CHARS_CEILING = 150_000;
56
69
  export function bashMaxOutputChars() {
@@ -84,10 +97,11 @@ export function shellRecoveryHint(path, readOnly) {
84
97
  }
85
98
  export const FILE_STATE_TRAILER = " (file state is current in your context — no need to Read it back)";
86
99
  export const CWD_SENTINEL = "__cc_cwd_9f2c1b__";
87
- export function msTimeoutToSec(timeoutMs) {
100
+ export function msTimeoutToSec(timeoutMs, caps = resolveBashTimeoutCaps()) {
101
+ const { defaultSec, maxSec } = bashTimeoutCapsSec(caps);
88
102
  if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
89
- return BASH_DEFAULT_TIMEOUT_SEC;
90
- return Math.min(BASH_MAX_TIMEOUT_SEC, Math.max(1, Math.round(timeoutMs / 1000)));
103
+ return defaultSec;
104
+ return Math.min(maxSec, Math.max(1, Math.round(timeoutMs / 1000)));
91
105
  }
92
106
  export function ipynbRedirect(toolName, path) {
93
107
  if (path.toLowerCase().endsWith(".ipynb")) {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { defineTool, errorResult } from "../../core/tools.js";
4
- import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
4
+ import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
5
5
  import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
6
6
  import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
7
7
  async function gateToolWrite(hook, tool, path, key, content) {
@@ -56,7 +56,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
56
56
  return errorResult(`Error (Edit): file_path is required.`);
57
57
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
58
58
  if (!r.ok)
59
- return errorResult(violationText("Edit", r.violation));
59
+ return errorResult(violationText("Edit", r.violation), violationDetails(r.violation));
60
60
  if (!batch && a.old_string === a.new_string) {
61
61
  return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
62
62
  }
@@ -241,7 +241,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
241
241
  return errorResult(ipynb);
242
242
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
243
243
  if (!r.ok)
244
- return errorResult(violationText("Write", r.violation));
244
+ return errorResult(violationText("Write", r.violation), violationDetails(r.violation));
245
245
  const exists = await env.exists(r.key, ctx.signal);
246
246
  if (!exists.ok)
247
247
  return errorResult(`Error (Write): cannot stat "${path}": ${exists.error.message}`);
@@ -331,7 +331,7 @@ export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additi
331
331
  return errorResult(`Error (NotebookEdit): cell_id is required for replace/delete.`);
332
332
  const r = await resolveKey(env, rootCanonical, notebook_path, ctx.signal, cwdRef?.current, additionalRoots);
333
333
  if (!r.ok)
334
- return errorResult(violationText("NotebookEdit", r.violation));
334
+ return errorResult(violationText("NotebookEdit", r.violation), violationDetails(r.violation));
335
335
  const notRead = requireRead(state, r.key);
336
336
  if (notRead)
337
337
  return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal));
@@ -29,6 +29,8 @@ export interface HandsToolkitOptions {
29
29
  }) => void;
30
30
  detachHub?: import("../../core/tool-detach.js").ToolDetachHub;
31
31
  execClamp?: ExecClampOption;
32
+ bashDefaultTimeoutMs?: number;
33
+ bashMaxTimeoutMs?: number;
32
34
  oneShot?: boolean;
33
35
  autoBackgroundOnTimeout?: boolean;
34
36
  readImageDownsampler?: ReadImageDownsamplerOption;