tinker-agent 1.5.1 → 1.7.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 (52) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +15 -7
  3. package/package.json +8 -7
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/runner-dependencies.ts +4 -1
  14. package/src/cli/tui-runner.tsx +17 -2
  15. package/src/events/bash-result-detail.ts +13 -6
  16. package/src/events/observation-text-log.ts +26 -1
  17. package/src/events/stdout-event-printer.ts +18 -2
  18. package/src/events/types.ts +14 -2
  19. package/src/model/fake-model-client.ts +177 -0
  20. package/src/model/model-client.ts +3 -0
  21. package/src/model/openai-chat-model-client.ts +54 -15
  22. package/src/model/openai-chat-stream.ts +95 -72
  23. package/src/observation/observation-builder.ts +54 -6
  24. package/src/session/session-catalog.ts +17 -11
  25. package/src/session/session-store.ts +2 -0
  26. package/src/tools/bash-guard.ts +131 -0
  27. package/src/tools/bash-task.ts +129 -90
  28. package/src/tools/bash.ts +75 -13
  29. package/src/tools/delete.ts +182 -0
  30. package/src/tools/edit.ts +68 -9
  31. package/src/tools/registry.ts +49 -3
  32. package/src/tools/shell-process.ts +296 -0
  33. package/src/tools/task-input.ts +229 -0
  34. package/src/tools/task-output-tool.ts +4 -1
  35. package/src/tools/terminal-screen.ts +105 -0
  36. package/src/tools/turn-undo-manager.ts +794 -0
  37. package/src/tools/types.ts +45 -0
  38. package/src/tools/write.ts +65 -14
  39. package/src/tui/app.tsx +161 -45
  40. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  41. package/src/tui/components/background-tasks.tsx +3 -2
  42. package/src/tui/components/bash-confirmation.tsx +27 -0
  43. package/src/tui/components/context-status.tsx +11 -1
  44. package/src/tui/components/footer.tsx +8 -5
  45. package/src/tui/components/prompt-input.tsx +13 -1
  46. package/src/tui/components/resume-session-picker.tsx +292 -46
  47. package/src/tui/components/timeline.tsx +10 -0
  48. package/src/tui/context-format.ts +17 -0
  49. package/src/tui/event-store.ts +76 -4
  50. package/src/tui/slash-commands.ts +28 -0
  51. package/src/tui/tui-projection-store.ts +246 -7
  52. package/src/tui/tui-session-controller.ts +19 -1
@@ -1,4 +1,3 @@
1
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
1
  import { mkdir, open, readFile, unlink } from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import type {
@@ -8,7 +7,19 @@ import type {
8
7
  import type { ToolCallIdentity } from "../agent/types";
9
8
  import { createUuidV7 } from "../ids/uuid-v7";
10
9
  import { isWorkspaceLocalCwd, type CwdState } from "./cwd-state";
10
+ import {
11
+ type ProcessExitResult,
12
+ type ShellProcessHandle,
13
+ type ShellProcessMode,
14
+ spawnShellProcess,
15
+ } from "./shell-process";
11
16
  import { TaskOutput, type TaskOutputSnapshot } from "./task-output";
17
+ import {
18
+ createTerminalScreen,
19
+ TERMINAL_SCREEN_COLUMNS,
20
+ TERMINAL_SCREEN_ROWS,
21
+ type TerminalScreen,
22
+ } from "./terminal-screen";
12
23
 
13
24
  export type ShellTaskStatus =
14
25
  | "running"
@@ -38,11 +49,15 @@ export type ShellTaskSnapshot = {
38
49
  outputBytes: number;
39
50
  outputLines: number;
40
51
  cwd: string;
52
+ tty: boolean;
41
53
  };
42
54
 
43
55
  export type ShellTaskInspection = {
44
56
  task: ShellTaskSnapshot;
45
57
  output: TaskOutputSnapshot;
58
+ screenRows?: number;
59
+ screenColumns?: number;
60
+ screen?: string;
46
61
  };
47
62
 
48
63
  export type ShellTaskHandle = {
@@ -81,9 +96,12 @@ type ManagedShellTask = {
81
96
  outputFilePath: string;
82
97
  cwdFilePath: string;
83
98
  cwd: string;
84
- process: ChildProcessWithoutNullStreams;
99
+ mode: ShellProcessMode;
100
+ process: ShellProcessHandle;
85
101
  processGroupId: number;
86
102
  output: TaskOutput;
103
+ terminalScreen?: TerminalScreen;
104
+ finalScreen?: string;
87
105
  completion: Promise<ShellTaskSnapshot>;
88
106
  stopPromise?: Promise<StopTaskResult>;
89
107
  terminalEventEmitted: boolean;
@@ -115,6 +133,7 @@ export class ShellTaskManager {
115
133
  command: string;
116
134
  description: string;
117
135
  origin: ShellTaskOrigin;
136
+ tty: boolean;
118
137
  }): Promise<ShellTaskHandle> {
119
138
  if (!this.acceptingTasks) {
120
139
  throw new Error("Cannot start a Bash task after task manager shutdown.");
@@ -142,22 +161,26 @@ export class ShellTaskManager {
142
161
  throw new Error("Cannot start a Bash task after task manager shutdown.");
143
162
  }
144
163
 
145
- const child = spawn("bash", ["-lc", bashWrapperScript], {
146
- cwd: this.options.cwdState.cwd,
147
- detached: true,
148
- env: {
149
- ...process.env,
150
- NO_COLOR: "1",
151
- TINKER_BASH_COMMAND: input.command,
152
- TINKER_BASH_CWD_FILE: cwdFilePath,
153
- },
154
- });
155
-
156
- if (child.pid === undefined) {
157
- const spawnError = await waitForProcessError(child);
164
+ const terminalScreen = input.tty ? createTerminalScreen() : undefined;
165
+ let shellProcess: ShellProcessHandle;
166
+ try {
167
+ shellProcess = await spawnShellProcess({
168
+ mode: input.tty ? "pty" : "pipe",
169
+ command: input.command,
170
+ cwd: this.options.cwdState.cwd,
171
+ cwdFilePath,
172
+ onOutput(bytes) {
173
+ output.write(Buffer.from(bytes));
174
+ if (terminalScreen !== undefined) {
175
+ void terminalScreen.write(bytes).catch(() => undefined);
176
+ }
177
+ },
178
+ });
179
+ } catch (error) {
180
+ terminalScreen?.dispose();
158
181
  await output.end();
159
182
  await unlinkIfExists(cwdFilePath);
160
- throw spawnError;
183
+ throw error;
161
184
  }
162
185
 
163
186
  const task: ManagedShellTask = {
@@ -170,15 +193,15 @@ export class ShellTaskManager {
170
193
  outputFilePath,
171
194
  cwdFilePath,
172
195
  cwd: this.options.cwdState.cwd,
173
- process: child,
174
- processGroupId: child.pid,
196
+ mode: shellProcess.mode,
197
+ process: shellProcess,
198
+ processGroupId: shellProcess.pid,
175
199
  output,
200
+ terminalScreen,
176
201
  completion: Promise.resolve(undefined as never),
177
202
  terminalEventEmitted: false,
178
203
  };
179
204
 
180
- pipeTaskOutput(task.process.stdout, task.output);
181
- pipeTaskOutput(task.process.stderr, task.output);
182
205
  task.completion = this.monitorTaskSafely(task);
183
206
  this.tasks.set(id, task);
184
207
 
@@ -231,10 +254,45 @@ export class ShellTaskManager {
231
254
  }
232
255
 
233
256
  this.synchronizeTerminalState(task);
234
- return {
235
- task: this.snapshot(task),
236
- output: task.output.snapshot(),
237
- };
257
+ return this.inspection(task);
258
+ }
259
+
260
+ async inspectTaskOutput(taskId: string): Promise<ShellTaskInspection | undefined> {
261
+ const task = this.tasks.get(taskId);
262
+ if (task === undefined) {
263
+ return undefined;
264
+ }
265
+
266
+ this.synchronizeTerminalState(task);
267
+ if (
268
+ task.mode === "pty" &&
269
+ isTerminalStatus(task.status) &&
270
+ task.finalScreen === undefined
271
+ ) {
272
+ await task.completion;
273
+ } else {
274
+ await task.terminalScreen?.flush();
275
+ }
276
+ return this.inspection(task);
277
+ }
278
+
279
+ taskCompletion(taskId: string): Promise<ShellTaskSnapshot> {
280
+ return this.requireTask(taskId).completion;
281
+ }
282
+
283
+ async writeTaskInput(taskId: string, chars: string): Promise<number> {
284
+ const task = this.requireTask(taskId);
285
+ this.synchronizeTerminalState(task);
286
+ if (task.mode !== "pty" || task.process.write === undefined) {
287
+ throw new Error(
288
+ `Task ${taskId} does not accept terminal input; start it with Bash tty=true.`,
289
+ );
290
+ }
291
+ if (task.status !== "running") {
292
+ throw new Error(`Task ${taskId} is not running (status=${task.status}).`);
293
+ }
294
+
295
+ return task.process.write(chars);
238
296
  }
239
297
 
240
298
  async stopTask(taskId: string, reason: StopTaskReason): Promise<StopTaskResult> {
@@ -372,20 +430,43 @@ export class ShellTaskManager {
372
430
  outputError instanceof Error ? outputError.message : String(outputError)
373
431
  }`;
374
432
  }
433
+ if (task.terminalScreen !== undefined) {
434
+ try {
435
+ await task.terminalScreen.flush();
436
+ task.finalScreen = task.terminalScreen.text();
437
+ } catch {
438
+ // The original monitor error remains the primary task failure.
439
+ }
440
+ task.terminalScreen.dispose();
441
+ }
442
+ task.process.close();
375
443
  await unlinkIfExists(task.cwdFilePath);
376
444
 
377
- return this.snapshot(task);
445
+ const snapshot = this.snapshot(task);
446
+ if (task.backgroundedAt !== undefined && !task.terminalEventEmitted) {
447
+ task.terminalEventEmitted = true;
448
+ await this.options.runtimeSession.append({
449
+ type: "bash.task.finished",
450
+ ...task.origin,
451
+ data: { task: snapshot },
452
+ });
453
+ }
454
+ return snapshot;
378
455
  }
379
456
  }
380
457
 
381
458
  private async monitorTask(task: ManagedShellTask): Promise<ShellTaskSnapshot> {
382
- const exit = waitForProcessExit(task.process);
383
- const close = waitForProcessClose(task.process);
384
- const result = await exit;
459
+ const result = await task.process.wait();
385
460
 
386
461
  this.applyTermination(task, result);
387
- await close;
462
+ await task.process.waitForOutputClose();
388
463
  await task.output.end();
464
+ if (task.terminalScreen !== undefined) {
465
+ await task.terminalScreen.flush();
466
+ task.finalScreen = task.terminalScreen.text();
467
+ task.terminalScreen.dispose();
468
+ }
469
+ task.process.close();
389
470
  await this.updateCwdFromFile(task);
390
471
  await unlinkIfExists(task.cwdFilePath);
391
472
 
@@ -464,6 +545,25 @@ export class ShellTaskManager {
464
545
  outputBytes: output.outputBytes,
465
546
  outputLines: output.outputLines,
466
547
  cwd: task.cwd,
548
+ tty: task.mode === "pty",
549
+ };
550
+ }
551
+
552
+ private inspection(task: ManagedShellTask): ShellTaskInspection {
553
+ const screen =
554
+ task.mode === "pty"
555
+ ? (task.finalScreen ?? task.terminalScreen?.text() ?? "")
556
+ : undefined;
557
+ return {
558
+ task: this.snapshot(task),
559
+ output: task.output.snapshot(),
560
+ ...(screen === undefined
561
+ ? {}
562
+ : {
563
+ screenRows: TERMINAL_SCREEN_ROWS,
564
+ screenColumns: TERMINAL_SCREEN_COLUMNS,
565
+ screen,
566
+ }),
467
567
  };
468
568
  }
469
569
 
@@ -488,54 +588,6 @@ export class ShellTaskManager {
488
588
  }
489
589
  }
490
590
 
491
- type ProcessExitResult = {
492
- code: number | null;
493
- signal: NodeJS.Signals | null;
494
- error?: string;
495
- };
496
-
497
- function waitForProcessExit(
498
- process: ChildProcessWithoutNullStreams,
499
- ): Promise<ProcessExitResult> {
500
- return new Promise((resolve) => {
501
- let settled = false;
502
- const finish = (result: ProcessExitResult) => {
503
- if (!settled) {
504
- settled = true;
505
- resolve(result);
506
- }
507
- };
508
-
509
- process.once("error", (error) => {
510
- finish({ code: null, signal: null, error: error.message });
511
- });
512
- process.once("exit", (code, signal) => {
513
- finish({ code, signal });
514
- });
515
- });
516
- }
517
-
518
- function waitForProcessError(process: ChildProcessWithoutNullStreams): Promise<Error> {
519
- return new Promise((resolve) => {
520
- process.once("error", resolve);
521
- });
522
- }
523
-
524
- function waitForProcessClose(process: ChildProcessWithoutNullStreams): Promise<void> {
525
- return new Promise((resolve) => {
526
- let settled = false;
527
- const finish = () => {
528
- if (!settled) {
529
- settled = true;
530
- resolve();
531
- }
532
- };
533
-
534
- process.once("error", finish);
535
- process.once("close", finish);
536
- });
537
- }
538
-
539
591
  function signalProcessGroup(
540
592
  task: ManagedShellTask,
541
593
  signal: "SIGTERM" | "SIGKILL",
@@ -573,12 +625,6 @@ function isTerminalStatus(status: ShellTaskStatus): boolean {
573
625
  return status === "completed" || status === "failed" || status === "killed";
574
626
  }
575
627
 
576
- function pipeTaskOutput(stream: NodeJS.ReadableStream, output: TaskOutput): void {
577
- stream.on("data", (chunk: Buffer | string) => {
578
- output.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
579
- });
580
- }
581
-
582
628
  async function ensureEmptyFile(filePath: string): Promise<void> {
583
629
  await mkdir(path.dirname(filePath), { recursive: true });
584
630
  const file = await open(filePath, "w");
@@ -608,10 +654,3 @@ function errorCode(error: unknown): unknown {
608
654
  ? error.code
609
655
  : undefined;
610
656
  }
611
-
612
- const bashWrapperScript = `
613
- eval "$TINKER_BASH_COMMAND"
614
- exit_code=$?
615
- pwd -P > "$TINKER_BASH_CWD_FILE"
616
- exit "$exit_code"
617
- `;
package/src/tools/bash.ts CHANGED
@@ -12,12 +12,14 @@ import { defineToolExecutor } from "./types";
12
12
  import type { TaskOutputSnapshot } from "./task-output";
13
13
  import type { BashRawResult, ToolExecutionContext, ToolExecutor } from "./types";
14
14
  import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
15
+ import { classifyBashRisk } from "./bash-guard";
15
16
 
16
17
  type BashArgs = {
17
18
  command: string;
18
19
  timeout?: number;
19
20
  description?: string;
20
21
  run_in_background?: boolean;
22
+ tty?: boolean;
21
23
  };
22
24
 
23
25
  export type BashToolOptions = {
@@ -65,6 +67,11 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
65
67
  type: "boolean",
66
68
  description: "Run the command in the background and return immediately.",
67
69
  },
70
+ tty: {
71
+ type: "boolean",
72
+ description:
73
+ "Run the command in a pseudo-terminal so it can receive interactive input.",
74
+ },
68
75
  },
69
76
  required: ["command"],
70
77
  },
@@ -86,26 +93,62 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
86
93
  outputLines: 0,
87
94
  preview: "",
88
95
  truncated: false,
96
+ tty: false,
89
97
  error: parsed.error,
90
98
  };
91
99
  }
92
100
 
93
101
  const input = parsed.value;
102
+ const risk = classifyBashRisk(input.command, {
103
+ workspaceRoot: options.workspaceRoot,
104
+ });
105
+ if (risk.dangerous && context.confirmBashCommand !== undefined) {
106
+ const decision = await context.confirmBashCommand({
107
+ command: input.command,
108
+ reason: risk.reason,
109
+ });
110
+ throwIfTurnCancelled(context.signal);
111
+ if (decision === "deny") {
112
+ const suffix =
113
+ context.bashGuardSurface === "one-shot"
114
+ ? "Non-interactive mode cannot confirm; rerun with --yolo."
115
+ : "The user declined this command.";
116
+ return {
117
+ ok: false,
118
+ command: input.command,
119
+ taskId: "",
120
+ sessionId: call.sessionId,
121
+ status: "failed",
122
+ cwd: options.cwdState.cwd,
123
+ outputFilePath: "",
124
+ outputBytes: 0,
125
+ outputLines: 0,
126
+ preview: "",
127
+ truncated: false,
128
+ tty: input.tty === true,
129
+ error: `Command denied: ${risk.reason}. ${suffix}`,
130
+ };
131
+ }
132
+ }
94
133
  const foregroundTimeoutMs = input.timeout ?? defaultTimeoutMs;
95
134
  throwIfTurnCancelled(context.signal);
96
135
  const task = await options.taskManager.start({
97
136
  command: input.command,
98
137
  description: input.description ?? input.command,
99
138
  origin: call,
139
+ tty: input.tty === true,
100
140
  });
101
141
 
102
142
  if (input.run_in_background === true) {
103
143
  // Starting and publishing an explicit background task is one commit
104
144
  // boundary. Cancellation is observed after its result is recorded.
105
145
  await options.taskManager.markBackgrounded(task.taskId, "requested");
106
- const inspection = requireTaskInspection(options.taskManager, task.taskId);
146
+ const inspection = await requireTaskOutputInspection(
147
+ options.taskManager,
148
+ task.taskId,
149
+ );
107
150
  if (inspection.task.status !== "running") {
108
- return buildCompletedResult(inspection.task, inspection.output);
151
+ return buildCompletedResult(inspection);
109
152
  }
110
153
 
111
154
  return buildRunningResult({
@@ -124,9 +167,12 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
124
167
  // Timeout wins ownership. Marking the task backgrounded and returning
125
168
  // its task ID is an uninterrupted commit boundary.
126
169
  await options.taskManager.markBackgrounded(task.taskId, "foreground_timeout");
127
- const inspection = requireTaskInspection(options.taskManager, task.taskId);
170
+ const inspection = await requireTaskOutputInspection(
171
+ options.taskManager,
172
+ task.taskId,
173
+ );
128
174
  if (inspection.task.status !== "running") {
129
- return buildCompletedResult(inspection.task, inspection.output);
175
+ return buildCompletedResult(inspection);
130
176
  }
131
177
 
132
178
  return buildRunningResult({
@@ -138,11 +184,14 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
138
184
  });
139
185
  }
140
186
 
141
- const inspection = requireTaskInspection(options.taskManager, task.taskId);
142
- const raw = await buildCompletedResult(waitResult.task, inspection.output);
187
+ const inspection = await requireTaskOutputInspection(
188
+ options.taskManager,
189
+ task.taskId,
190
+ );
191
+ const raw = await buildCompletedResult(inspection);
143
192
  updateCwdStateAfterForegroundCommand({
144
193
  raw,
145
- task: waitResult.task,
194
+ task: inspection.task,
146
195
  cwdState: options.cwdState,
147
196
  workspaceRoot: options.workspaceRoot,
148
197
  });
@@ -179,6 +228,10 @@ export function parseBashArgs(
179
228
  return { ok: false, error: "Bash.run_in_background must be a boolean." };
180
229
  }
181
230
 
231
+ if (args.tty !== undefined && typeof args.tty !== "boolean") {
232
+ return { ok: false, error: "Bash.tty must be a boolean." };
233
+ }
234
+
182
235
  return {
183
236
  ok: true,
184
237
  value: {
@@ -189,6 +242,7 @@ export function parseBashArgs(
189
242
  ? undefined
190
243
  : args.description,
191
244
  run_in_background: args.run_in_background,
245
+ tty: args.tty,
192
246
  },
193
247
  };
194
248
  }
@@ -294,18 +348,22 @@ function buildRunningResult(input: {
294
348
  timeoutMs: input.timeoutMs,
295
349
  backgrounded: input.backgrounded,
296
350
  backgroundedDueToTimeout: input.backgroundedDueToTimeout,
351
+ tty: task.tty,
352
+ screenRows: input.inspection.screenRows,
353
+ screenColumns: input.inspection.screenColumns,
354
+ screen: input.inspection.screen,
297
355
  };
298
356
  }
299
357
 
300
358
  async function buildCompletedResult(
301
- task: ShellTaskSnapshot,
302
- fallbackOutput: TaskOutputSnapshot,
359
+ inspection: ShellTaskInspection,
303
360
  ): Promise<BashRawResult> {
361
+ const { task } = inspection;
304
362
  if (task.status === "running" || task.status === "stopping") {
305
363
  throw new Error(`Bash task ${task.taskId} completed with status=${task.status}.`);
306
364
  }
307
365
 
308
- const snapshot = await snapshotCompletedOutput(task, fallbackOutput);
366
+ const snapshot = await snapshotCompletedOutput(task, inspection.output);
309
367
  const interpretation = interpretCommandResult({
310
368
  command: task.command,
311
369
  exitCode: task.exitCode,
@@ -328,6 +386,10 @@ async function buildCompletedResult(
328
386
  truncated: snapshot.truncated,
329
387
  omittedLines: snapshot.omittedLines,
330
388
  returnCodeInterpretation: interpretation.interpretation,
389
+ tty: task.tty,
390
+ screenRows: inspection.screenRows,
391
+ screenColumns: inspection.screenColumns,
392
+ screen: inspection.screen,
331
393
  error: task.error,
332
394
  };
333
395
  }
@@ -359,11 +421,11 @@ function updateCwdStateAfterForegroundCommand(input: {
359
421
  }
360
422
  }
361
423
 
362
- function requireTaskInspection(
424
+ async function requireTaskOutputInspection(
363
425
  taskManager: ShellTaskManager,
364
426
  taskId: string,
365
- ): ShellTaskInspection {
366
- const inspection = taskManager.inspectTask(taskId);
427
+ ): Promise<ShellTaskInspection> {
428
+ const inspection = await taskManager.inspectTaskOutput(taskId);
367
429
  if (inspection === undefined) {
368
430
  throw new Error(`Bash task disappeared from task manager: ${taskId}`);
369
431
  }
@@ -0,0 +1,182 @@
1
+ import { lstat, readFile, rm } from "node:fs/promises";
2
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
3
+ import { resolveWorkspacePath } from "./path-safety";
4
+ import type { TurnUndoManager } from "./turn-undo-manager";
5
+ import { defineToolExecutor } from "./types";
6
+ import type {
7
+ DeleteFileRawResult,
8
+ FileSnapshotStore,
9
+ ToolExecutionContext,
10
+ ToolExecutor,
11
+ } from "./types";
12
+
13
+ type DeleteArgs = {
14
+ file_path: string;
15
+ };
16
+
17
+ export type DeleteToolOptions = {
18
+ workspaceRoot: string;
19
+ snapshots: FileSnapshotStore;
20
+ undoManager?: TurnUndoManager;
21
+ };
22
+
23
+ export function createDeleteToolExecutor(options: DeleteToolOptions): ToolExecutor {
24
+ return defineToolExecutor("delete", {
25
+ definition: {
26
+ name: "Delete",
27
+ description:
28
+ "Delete one existing regular file. Directories and symbolic links are not supported.",
29
+ parameters: {
30
+ type: "object",
31
+ additionalProperties: false,
32
+ properties: {
33
+ file_path: {
34
+ type: "string",
35
+ description: "Workspace-relative path or absolute path.",
36
+ },
37
+ },
38
+ required: ["file_path"],
39
+ },
40
+ },
41
+ async execute(
42
+ args,
43
+ call,
44
+ context: ToolExecutionContext,
45
+ ): Promise<DeleteFileRawResult> {
46
+ throwIfTurnCancelled(context.signal);
47
+ const parsed = parseDeleteArgs(args);
48
+
49
+ if (!parsed.ok) {
50
+ return {
51
+ ok: false,
52
+ filePath: "",
53
+ error: parsed.error,
54
+ };
55
+ }
56
+
57
+ const input = parsed.value;
58
+ let absolutePath: string;
59
+
60
+ try {
61
+ absolutePath = resolveWorkspacePath(options.workspaceRoot, input.file_path);
62
+ } catch (error) {
63
+ return {
64
+ ok: false,
65
+ filePath: input.file_path,
66
+ error: errorMessage(error),
67
+ };
68
+ }
69
+
70
+ let info: Awaited<ReturnType<typeof lstat>>;
71
+ try {
72
+ info = await lstat(absolutePath);
73
+ } catch (error) {
74
+ return {
75
+ ok: false,
76
+ filePath: input.file_path,
77
+ absolutePath,
78
+ error: deleteErrorMessage(error),
79
+ };
80
+ }
81
+
82
+ if (info.isSymbolicLink()) {
83
+ return {
84
+ ok: false,
85
+ filePath: input.file_path,
86
+ absolutePath,
87
+ error: "Symbolic links are not supported.",
88
+ };
89
+ }
90
+
91
+ if (!info.isFile()) {
92
+ return {
93
+ ok: false,
94
+ filePath: input.file_path,
95
+ absolutePath,
96
+ error: "Path is not a regular file.",
97
+ };
98
+ }
99
+
100
+ const undoCapture = await options.undoManager?.captureBeforeMutation({
101
+ turnId: call.turnId,
102
+ turnNumber: call.turnNumber,
103
+ absolutePath,
104
+ displayPath: input.file_path,
105
+ knownByteLength: info.size,
106
+ loadBefore: async () => ({
107
+ state: "present",
108
+ bytes: await readFile(absolutePath),
109
+ }),
110
+ });
111
+
112
+ try {
113
+ throwIfTurnCancelled(context.signal);
114
+ await rm(absolutePath);
115
+ } catch (error) {
116
+ if (undoCapture !== undefined) {
117
+ await options.undoManager?.recordMutationFailure(undoCapture);
118
+ }
119
+ if (context.signal.aborted) {
120
+ throw error;
121
+ }
122
+ return {
123
+ ok: false,
124
+ filePath: input.file_path,
125
+ absolutePath,
126
+ error: deleteErrorMessage(error),
127
+ };
128
+ }
129
+
130
+ if (undoCapture !== undefined) {
131
+ options.undoManager?.recordMutationResult(undoCapture, { state: "absent" });
132
+ }
133
+ options.snapshots.delete(absolutePath);
134
+
135
+ return {
136
+ ok: true,
137
+ filePath: input.file_path,
138
+ absolutePath,
139
+ };
140
+ },
141
+ });
142
+ }
143
+
144
+ function parseDeleteArgs(
145
+ args: unknown,
146
+ ): { ok: true; value: DeleteArgs } | { ok: false; error: string } {
147
+ if (!isRecord(args)) {
148
+ return { ok: false, error: "Delete arguments must be an object." };
149
+ }
150
+
151
+ if (typeof args.file_path !== "string") {
152
+ return { ok: false, error: "Delete.file_path must be a string." };
153
+ }
154
+
155
+ return {
156
+ ok: true,
157
+ value: {
158
+ file_path: args.file_path,
159
+ },
160
+ };
161
+ }
162
+
163
+ function isRecord(value: unknown): value is Record<string, unknown> {
164
+ return typeof value === "object" && value !== null && !Array.isArray(value);
165
+ }
166
+
167
+ function deleteErrorMessage(error: unknown): string {
168
+ return isNotFound(error) ? "File does not exist." : errorMessage(error);
169
+ }
170
+
171
+ function isNotFound(error: unknown): boolean {
172
+ return (
173
+ typeof error === "object" &&
174
+ error !== null &&
175
+ "code" in error &&
176
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
177
+ );
178
+ }
179
+
180
+ function errorMessage(error: unknown): string {
181
+ return error instanceof Error ? error.message : String(error);
182
+ }