@cjhyy/code-shell-core 0.7.0 → 0.7.1

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 (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. package/package.json +1 -1
@@ -10,11 +10,15 @@ export class Transcript {
10
10
  events = [];
11
11
  filePath;
12
12
  currentTurn = 0;
13
+ writer;
14
+ dirty = false;
15
+ lastFlushFailure;
13
16
  getFilePath() {
14
17
  return this.filePath;
15
18
  }
16
- constructor(filePath) {
19
+ constructor(filePath, writer = appendFileSync) {
17
20
  this.filePath = filePath;
21
+ this.writer = writer;
18
22
  mkdirSync(dirname(filePath), { recursive: true });
19
23
  if (!existsSync(filePath)) {
20
24
  writeFileSync(filePath, "", "utf-8");
@@ -179,18 +183,65 @@ export class Transcript {
179
183
  get eventCount() {
180
184
  return this.events.length;
181
185
  }
186
+ /** True once any event failed both persistence attempts. Sticky by design. */
187
+ flushFailed() {
188
+ return this.dirty;
189
+ }
190
+ /** Structured details for the most recent unrecoverable flush failure. */
191
+ getFlushFailure() {
192
+ return this.lastFlushFailure ? { ...this.lastFlushFailure } : undefined;
193
+ }
182
194
  findMessageByClientId(clientMessageId) {
183
195
  return this.events.find((event) => event.type === "message" &&
184
196
  event.data.clientMessageId === clientMessageId);
185
197
  }
186
198
  flush(event) {
199
+ const line = JSON.stringify(event) + "\n";
187
200
  try {
188
- appendFileSync(this.filePath, JSON.stringify(event) + "\n", "utf-8");
201
+ this.writer(this.filePath, line, "utf-8");
202
+ return true;
189
203
  }
190
- catch {
191
- // Silently fail on flush errors — events are still in memory
204
+ catch (firstError) {
205
+ try {
206
+ this.writer(this.filePath, line, "utf-8");
207
+ logger.warn("transcript.flush_retry_recovered", {
208
+ filePath: this.filePath,
209
+ errno: this.errorErrno(firstError),
210
+ message: this.errorMessage(firstError),
211
+ });
212
+ return true;
213
+ }
214
+ catch (retryError) {
215
+ const err = retryError;
216
+ const failure = {
217
+ errno: this.errorErrno(retryError),
218
+ ...(typeof err?.code === "string" ? { code: err.code } : {}),
219
+ message: this.errorMessage(retryError),
220
+ timestamp: Date.now(),
221
+ attempts: 2,
222
+ recoverable: false,
223
+ filePath: this.filePath,
224
+ };
225
+ // Sticky: a later successful append cannot restore an earlier missing
226
+ // JSONL event, so the transcript remains degraded for this instance.
227
+ this.dirty = true;
228
+ this.lastFlushFailure = failure;
229
+ logger.error("transcript.flush_failed", { ...failure });
230
+ return false;
231
+ }
192
232
  }
193
233
  }
234
+ errorErrno(error) {
235
+ const err = error;
236
+ if (typeof err?.errno === "number" || typeof err?.errno === "string")
237
+ return err.errno;
238
+ if (typeof err?.code === "string")
239
+ return err.code;
240
+ return "UNKNOWN";
241
+ }
242
+ errorMessage(error) {
243
+ return error instanceof Error ? error.message : String(error);
244
+ }
194
245
  /**
195
246
  * Repair tool_result pairing issues:
196
247
  * - Orphaned tool_results (no matching tool_use) get removed
@@ -227,6 +278,70 @@ export class Transcript {
227
278
  return true;
228
279
  });
229
280
  }
281
+ static readEvents(filePath) {
282
+ let raw;
283
+ try {
284
+ raw = readFileSync(filePath, "utf-8");
285
+ }
286
+ catch {
287
+ return { events: [], malformedLineCount: 0 };
288
+ }
289
+ const lines = raw.split("\n").filter((line) => line.trim().length > 0);
290
+ const events = [];
291
+ let malformedLineCount = 0;
292
+ for (const line of lines) {
293
+ try {
294
+ events.push(JSON.parse(line));
295
+ }
296
+ catch {
297
+ malformedLineCount++;
298
+ }
299
+ }
300
+ return { events, malformedLineCount };
301
+ }
302
+ static eventsToMessages(events) {
303
+ const messages = [];
304
+ for (const event of events) {
305
+ switch (event.type) {
306
+ case "message": {
307
+ const { role, content } = event.data;
308
+ messages.push({ role, content });
309
+ break;
310
+ }
311
+ case "tool_use":
312
+ break;
313
+ case "tool_result": {
314
+ const { toolCallId, result, error, contentBlocks } = event.data;
315
+ const block = {
316
+ type: "tool_result",
317
+ tool_use_id: toolCallId,
318
+ content: error
319
+ ? `Error: ${error}`
320
+ : Array.isArray(contentBlocks) && contentBlocks.length > 0
321
+ ? contentBlocks
322
+ : (result ?? "(no output)"),
323
+ };
324
+ const last = messages[messages.length - 1];
325
+ if (last?.role === "user" && Array.isArray(last.content)) {
326
+ last.content.push(block);
327
+ }
328
+ else {
329
+ messages.push({ role: "user", content: [block] });
330
+ }
331
+ break;
332
+ }
333
+ case "summary": {
334
+ const { summary } = event.data;
335
+ messages.push({
336
+ role: "user",
337
+ content: `<system-reminder>Previous conversation was summarized:\n${summary}</system-reminder>`,
338
+ });
339
+ break;
340
+ }
341
+ }
342
+ }
343
+ return messages;
344
+ }
230
345
  static loadFromFile(filePath) {
231
346
  const transcript = new Transcript(filePath);
232
347
  if (!existsSync(filePath))
@@ -269,7 +269,9 @@ streamOverride) {
269
269
  // that no longer exists. The child Engine is a fresh instance; plan-mode
270
270
  // isolation between parent and child is enforced via separate Engine
271
271
  // instances (finalized in T6).
272
- const { text } = await spawner.spawn({ ...opts, streamOverride });
272
+ const { text, usage } = await spawner.spawn({ ...opts, streamOverride });
273
+ if (usage)
274
+ opts.recordBilledUsage?.(usage);
273
275
  const finalText = text || `Agent completed but produced no text output.`;
274
276
  safeEmit(startEndSink, { type: "agent_end", agentId, name, description, text: finalText, agentType });
275
277
  emitSubAgentHook(opts.hooks, "subagent_finish", { agentId, description, text: finalText });
@@ -372,6 +374,7 @@ export async function agentTool(args, ctx) {
372
374
  appendSystemPrompt: overrides.appendSystemPrompt,
373
375
  readOnlySession: overrides.resolvedType === "researcher" || overrides.resolvedType === "explorer",
374
376
  hooks: ctx?.hooks,
377
+ recordBilledUsage: ctx?.recordBilledUsage,
375
378
  signal: controller.signal,
376
379
  }, parentStream, // uiStream: agent_start/end → main feed
377
380
  transcriptSink)
@@ -581,6 +584,7 @@ async function runSyncSubAgent(args) {
581
584
  readOnlySession: overrides.resolvedType === "researcher" || overrides.resolvedType === "explorer",
582
585
  resumeSessionId,
583
586
  hooks: ctx?.hooks,
587
+ recordBilledUsage: ctx?.recordBilledUsage,
584
588
  signal: syncController.signal,
585
589
  });
586
590
  // Sentinel that the timer resolves with, so we can tell "agent finished" from
@@ -390,6 +390,7 @@ export async function arenaTool(args, ctx) {
390
390
  signal,
391
391
  });
392
392
  const result = await arena.run(topic);
393
+ ctx.recordBilledUsage?.(result.usage);
393
394
  // Use session renderer (markdown, no ANSI) for tool results.
394
395
  // Terminal renderer (chalk) is only for direct stdout printing.
395
396
  const formattedResult = formatArenaResultForSession(result);
@@ -23,6 +23,7 @@
23
23
  type Listener = () => void;
24
24
  export type BackgroundJobStatus = "running" | "cancelling" | "completed" | "failed" | "cancelled";
25
25
  export type BackgroundJobKind = "drive-agent" | "video" | "job";
26
+ export type ExternalCliKind = "claude" | "codex";
26
27
  /** A background job, running or finished. */
27
28
  export interface BackgroundJobEntry {
28
29
  jobId: string;
@@ -48,11 +49,13 @@ export interface BackgroundJobEntry {
48
49
  /** DriveAgent prompt summary, separate from the UI-oriented description. */
49
50
  promptSummary?: string;
50
51
  /** External CLI kind for DriveAgent jobs. */
51
- cli?: string;
52
+ cli?: ExternalCliKind;
52
53
  /** Client message that launched this external job. */
53
54
  originClientMessageId?: string;
54
- /** Optional cancellation hook for jobs backed by a live process. */
55
- abort?: () => void | Promise<void>;
55
+ /** Optional cancellation hook for jobs backed by a live process. It may
56
+ * return a richer terminal outcome when cancellation races with a result
57
+ * that must still be retained (for example, a remotely rendered video). */
58
+ abort?: () => void | BackgroundJobOutcome | Promise<void | BackgroundJobOutcome>;
56
59
  }
57
60
  /** Outcome passed to finish() to record how a job ended. */
58
61
  export interface BackgroundJobOutcome {
@@ -65,9 +68,9 @@ export interface BackgroundJobStartOptions {
65
68
  kind?: BackgroundJobKind;
66
69
  cwd?: string;
67
70
  promptSummary?: string;
68
- cli?: string;
71
+ cli?: ExternalCliKind;
69
72
  originClientMessageId?: string;
70
- abort?: () => void | Promise<void>;
73
+ abort?: () => void | BackgroundJobOutcome | Promise<void | BackgroundJobOutcome>;
71
74
  }
72
75
  declare class BackgroundJobRegistry {
73
76
  private jobs;
@@ -34,16 +34,18 @@ function isActiveStatus(status) {
34
34
  async function waitForAbort(abort) {
35
35
  let timer;
36
36
  try {
37
- await Promise.race([
37
+ const result = await Promise.race([
38
38
  Promise.resolve().then(abort),
39
39
  new Promise((resolve) => {
40
- timer = setTimeout(resolve, CANCEL_WAIT_TIMEOUT_MS);
40
+ timer = setTimeout(() => resolve(undefined), CANCEL_WAIT_TIMEOUT_MS);
41
41
  }),
42
42
  ]);
43
+ return result && typeof result === "object" ? result : undefined;
43
44
  }
44
45
  catch {
45
46
  // Cancellation is best-effort; the terminal state still closes after the
46
47
  // abort hook settles or the hard deadline expires.
48
+ return undefined;
47
49
  }
48
50
  finally {
49
51
  if (timer)
@@ -118,20 +120,22 @@ class BackgroundJobRegistry {
118
120
  // process as active until the abort hook confirms exit.
119
121
  entry.status = "cancelling";
120
122
  this.notify();
121
- if (entry.abort)
122
- await waitForAbort(entry.abort);
123
+ const abortOutcome = entry.abort ? await waitForAbort(entry.abort) : undefined;
123
124
  // Session teardown/reset may have removed the entry while termination was
124
125
  // in flight. Do not resurrect or notify for a closed session.
125
126
  if (this.jobs.get(jobId) !== entry || entry.status !== "cancelling")
126
127
  return false;
127
- entry.status = "cancelled";
128
+ entry.status = abortOutcome?.status ?? "cancelled";
128
129
  entry.finishedAt = Date.now();
129
- if (outcome?.finalText !== undefined)
130
- entry.finalText = outcome.finalText;
131
- if (outcome?.ccSessionId !== undefined)
132
- entry.ccSessionId = outcome.ccSessionId;
133
- if (outcome?.changedFiles !== undefined)
134
- entry.changedFiles = outcome.changedFiles;
130
+ const finalText = abortOutcome?.finalText ?? outcome?.finalText;
131
+ const ccSessionId = abortOutcome?.ccSessionId ?? outcome?.ccSessionId;
132
+ const changedFiles = abortOutcome?.changedFiles ?? outcome?.changedFiles;
133
+ if (finalText !== undefined)
134
+ entry.finalText = finalText;
135
+ if (ccSessionId !== undefined)
136
+ entry.ccSessionId = ccSessionId;
137
+ if (changedFiles !== undefined)
138
+ entry.changedFiles = changedFiles;
135
139
  this.evictTerminalOverCap(entry.sessionId);
136
140
  this.notify();
137
141
  return true;
@@ -1,5 +1,5 @@
1
1
  import { type AsyncAgentStatus } from "./agent-registry.js";
2
- import { type BackgroundJobStatus } from "./background-jobs.js";
2
+ import { type BackgroundJobKind, type BackgroundJobStatus, type ExternalCliKind } from "./background-jobs.js";
3
3
  import { type BgShell } from "../../runtime/background-shell.js";
4
4
  export type BackgroundWorkKind = "subagent" | "job" | "shell";
5
5
  export interface BackgroundWorkItem {
@@ -51,6 +51,11 @@ export type BackgroundWorkEntry = WithSource<{
51
51
  finalText?: string;
52
52
  /** Files an external agent (DriveAgent) changed, parsed from its transcript. */
53
53
  changedFiles?: string[];
54
+ /** Machine-readable job kind and external-session linkage for DriveAgent. */
55
+ jobKind?: BackgroundJobKind;
56
+ externalSessionId?: string;
57
+ cli?: ExternalCliKind;
58
+ cwd?: string;
54
59
  }>;
55
60
  /**
56
61
  * Every background-work item spawned by `sessionId`, with per-kind detail, for
@@ -14,7 +14,7 @@
14
14
  import { readFileSync, statSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { asyncAgentRegistry } from "./agent-registry.js";
17
- import { backgroundJobRegistry } from "./background-jobs.js";
17
+ import { backgroundJobRegistry, } from "./background-jobs.js";
18
18
  import { backgroundShellManager } from "../../runtime/background-shell.js";
19
19
  import { codeShellHome } from "../../session/session-manager.js";
20
20
  /** List every still-running background work item spawned by `sessionId`. */
@@ -125,6 +125,10 @@ export function listBackgroundWorkForUI(sessionId, opts = {}) {
125
125
  ...(j.finishedAt != null ? { finishedAt: j.finishedAt } : {}),
126
126
  ...(j.finalText != null ? { finalText: j.finalText } : {}),
127
127
  ...(j.changedFiles && j.changedFiles.length ? { changedFiles: j.changedFiles } : {}),
128
+ ...(j.kind ? { jobKind: j.kind } : {}),
129
+ ...(j.ccSessionId ? { externalSessionId: j.ccSessionId } : {}),
130
+ ...(j.cli === "claude" || j.cli === "codex" ? { cli: j.cli } : {}),
131
+ ...(j.cwd ? { cwd: j.cwd } : {}),
128
132
  sourceSession: sourceSession(sessionId, j.sessionId),
129
133
  });
130
134
  }
@@ -16,9 +16,7 @@
16
16
  * Killed by signal, the truncation message, sandbox denial hints).
17
17
  */
18
18
  import type { ToolContext } from "../context.js";
19
- import type { ToolDefinition, ToolResult } from "../../types.js";
19
+ import type { ToolDefinition } from "../../types.js";
20
+ import type { ToolExecutionResult } from "./index.js";
20
21
  export declare const bashToolDef: ToolDefinition;
21
- export declare function bashTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string | {
22
- result: string;
23
- sandbox: ToolResult["sandbox"];
24
- }>;
22
+ export declare function bashTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string | ToolExecutionResult>;
@@ -82,7 +82,8 @@ export async function bashTool(args, ctx) {
82
82
  const sandbox = sandboxMark(backend);
83
83
  // Tag every textual return below with the sandbox mark so the UI badge
84
84
  // shows up whether the command succeeded, failed, timed out, or aborted.
85
- const mark = (result) => ({ result, sandbox });
85
+ const mark = (result) => ({ ok: true, result, sandbox });
86
+ const fail = (error, details = {}) => ({ ok: false, error, sandbox, ...details });
86
87
  // Project localEnvironment.env (ctx.shellEnv) layers on top of either the
87
88
  // full passthrough (off) or the hardened allowlist (sandboxed) — see
88
89
  // mergeShellEnv. Background mode merges the same way inside the manager.
@@ -93,7 +94,8 @@ export async function bashTool(args, ctx) {
93
94
  // blocked, and Engine.run's wait-for-background loop never waits on it
94
95
  // (§难点5: that loop only looks at asyncAgentRegistry).
95
96
  if (args.run_in_background === true) {
96
- return mark(runInBackground(command, ctx));
97
+ const backgroundResult = runInBackground(command, ctx);
98
+ return backgroundResult.startsWith("Error:") ? fail(backgroundResult) : mark(backgroundResult);
97
99
  }
98
100
  // A6: lifecycle (spawn, kill cascade, IO drain, byte cap, abort/timeout)
99
101
  // is centralized in safeSpawnShell. We still handle Bash's user-facing
@@ -112,13 +114,13 @@ export async function bashTool(args, ctx) {
112
114
  signal: ctx?.signal,
113
115
  });
114
116
  if (result.aborted) {
115
- return mark(wasAbortedBeforeStart ? "Bash aborted before starting." : "Bash aborted by signal.");
117
+ return fail(wasAbortedBeforeStart ? "Bash aborted before starting." : "Bash aborted by signal.");
116
118
  }
117
119
  if (result.timedOut) {
118
- return mark(`Command timed out after ${timeout}ms`);
120
+ return fail(`Command timed out after ${timeout}ms`);
119
121
  }
120
122
  if (result.spawnFailed) {
121
- return mark(`Failed to spawn command: ${result.error ?? "unknown error"}`);
123
+ return fail(`Failed to spawn command: ${result.error ?? "unknown error"}`);
122
124
  }
123
125
  let body = "";
124
126
  if (result.stdout)
@@ -150,6 +152,9 @@ export async function bashTool(args, ctx) {
150
152
  const hint = backend.hintForBlockedOutput?.(result.stderr);
151
153
  if (hint)
152
154
  output += hint;
155
+ if ((code !== 0 && code !== null) || (code === null && sig)) {
156
+ return fail(output, { exitCode: code, signal: sig, stderr: result.stderr });
157
+ }
153
158
  return mark(output);
154
159
  }
155
160
  /**
@@ -22,9 +22,9 @@
22
22
  */
23
23
  import type { ToolDefinition } from "../../types.js";
24
24
  import type { ToolContext } from "../context.js";
25
- import type { BuiltinToolResult } from "./index.js";
25
+ import type { BuiltinToolReturn } from "./index.js";
26
26
  export declare const browserObserveToolDef: ToolDefinition;
27
- export declare function browserObserveTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<BuiltinToolResult>;
27
+ export declare function browserObserveTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<BuiltinToolReturn>;
28
28
  export declare const browserActToolDef: ToolDefinition;
29
29
  export declare function browserActTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
30
30
  export declare const browserNavigateToolDef: ToolDefinition;
@@ -3,5 +3,6 @@
3
3
  */
4
4
  import type { ToolDefinition } from "../../types.js";
5
5
  import type { ToolContext } from "../context.js";
6
+ import type { ToolFailure } from "./index.js";
6
7
  export declare const editToolDef: ToolDefinition;
7
- export declare function editTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
8
+ export declare function editTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string | ToolFailure>;
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Built-in Edit file tool — exact string replacement.
3
3
  */
4
- import { readFile, writeFile } from "node:fs/promises";
4
+ import { readFile } from "node:fs/promises";
5
5
  import { existsSync } from "node:fs";
6
6
  import { isAbsolute, resolve } from "node:path";
7
7
  import { fileCache } from "./file-cache.js";
8
8
  import { detectEol, toLf, applyEol } from "./eol.js";
9
+ import { getFinalWritePathSnapshot, revalidateFinalWritePath, writeFileNoFollow, } from "../path-policy.js";
9
10
  export const editToolDef = {
10
11
  name: "Edit",
11
12
  description: "Perform exact string replacements in a file. " +
@@ -50,7 +51,11 @@ export async function editTool(args, ctx) {
50
51
  if (!existsSync(filePath))
51
52
  return `Error: File not found: ${filePath}`;
52
53
  try {
53
- const raw = await readFile(filePath, "utf-8");
54
+ const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd);
55
+ const beforeRead = revalidateFinalWritePath(filePath, cwd, approvedPath);
56
+ if ("error" in beforeRead)
57
+ return { ok: false, error: beforeRead.error };
58
+ const raw = await readFile(beforeRead.resolvedPath, "utf-8");
54
59
  // CRLF handling: detect the file's line ending, then match/replace in LF
55
60
  // space (the model emits LF, so a CRLF file would never match otherwise),
56
61
  // and write back in the ORIGINAL EOL so a CRLF file stays CRLF (no whole-
@@ -72,7 +77,10 @@ export async function editTool(args, ctx) {
72
77
  const updatedLf = replaceAll
73
78
  ? content.split(oldLf).join(newLf)
74
79
  : content.replace(oldLf, newLf);
75
- await writeFile(filePath, applyEol(updatedLf, eol), "utf-8");
80
+ const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath);
81
+ if ("error" in beforeWrite)
82
+ return { ok: false, error: beforeWrite.error };
83
+ await writeFileNoFollow(beforeWrite.resolvedPath, applyEol(updatedLf, eol));
76
84
  fileCache.invalidate(filePath);
77
85
  const count = replaceAll
78
86
  ? (content.split(oldLf).length - 1)
@@ -84,7 +92,7 @@ export async function editTool(args, ctx) {
84
92
  return `Successfully edited ${filePath} (${count} replacement${count > 1 ? "s" : ""})\n${diffSummary}`;
85
93
  }
86
94
  catch (err) {
87
- return `Error editing file: ${err.message}`;
95
+ return { ok: false, error: `Error editing file: ${err.message}` };
88
96
  }
89
97
  }
90
98
  function generateCompactDiff(oldLines, newLines, filePath) {
@@ -32,6 +32,10 @@ export declare function __normalizeImagesForTests(images: string[] | undefined,
32
32
  ok: false;
33
33
  error: string;
34
34
  }>;
35
+ export declare function __setVideoPollingLimitsForTests(limits: {
36
+ maxPollMs: number;
37
+ requestTimeoutMs: number;
38
+ } | null): void;
35
39
  export declare const generateVideoToolDef: ToolDefinition;
36
40
  export declare function __setVideoProviderForTests(p: VideoProvider | null): void;
37
41
  export declare function isGenerateVideoAvailable(cwd?: string, nowMs?: number): boolean;