tinker-agent 1.5.0 → 1.6.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 (48) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +13 -5
  3. package/package.json +7 -5
  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/tui-runner.tsx +21 -2
  14. package/src/events/observation-text-log.ts +21 -0
  15. package/src/events/stdout-event-printer.ts +11 -0
  16. package/src/events/types.ts +14 -2
  17. package/src/model/fake-model-client.ts +190 -0
  18. package/src/model/model-client.ts +3 -0
  19. package/src/model/openai-chat-model-client.ts +54 -15
  20. package/src/model/openai-chat-stream.ts +95 -72
  21. package/src/observation/observation-builder.ts +11 -0
  22. package/src/session/session-store.ts +1 -0
  23. package/src/tools/bash-guard.ts +131 -0
  24. package/src/tools/bash.ts +31 -0
  25. package/src/tools/delete.ts +182 -0
  26. package/src/tools/edit.ts +68 -9
  27. package/src/tools/registry.ts +47 -3
  28. package/src/tools/turn-undo-manager.ts +794 -0
  29. package/src/tools/types.ts +13 -0
  30. package/src/tools/write.ts +65 -14
  31. package/src/tui/app.tsx +301 -134
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/assistant-markdown.tsx +27 -26
  34. package/src/tui/components/background-tasks.tsx +7 -2
  35. package/src/tui/components/bash-confirmation.tsx +27 -0
  36. package/src/tui/components/context-status.tsx +11 -1
  37. package/src/tui/components/file-viewer.tsx +2 -2
  38. package/src/tui/components/footer.tsx +9 -12
  39. package/src/tui/components/memory-browser.tsx +1 -1
  40. package/src/tui/components/prompt-input.tsx +13 -1
  41. package/src/tui/components/resume-session-picker.tsx +3 -1
  42. package/src/tui/components/timeline.tsx +19 -11
  43. package/src/tui/context-format.ts +17 -0
  44. package/src/tui/event-store.ts +75 -3
  45. package/src/tui/shiki-highlighter.ts +104 -0
  46. package/src/tui/slash-commands.ts +28 -0
  47. package/src/tui/tui-projection-store.ts +277 -5
  48. package/src/tui/tui-session-controller.ts +32 -8
@@ -0,0 +1,131 @@
1
+ import path from "node:path";
2
+
3
+ export type BashRisk =
4
+ | { readonly dangerous: false }
5
+ | { readonly dangerous: true; readonly reason: string };
6
+
7
+ export type BashRiskContext = {
8
+ readonly workspaceRoot?: string;
9
+ };
10
+
11
+ const SAFE: BashRisk = Object.freeze({ dangerous: false });
12
+
13
+ export function classifyBashRisk(
14
+ command: string,
15
+ context: BashRiskContext = {},
16
+ ): BashRisk {
17
+ const normalized = command.trim();
18
+ if (normalized === "") {
19
+ return SAFE;
20
+ }
21
+
22
+ if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(normalized)) {
23
+ return dangerous("fork bomb");
24
+ }
25
+
26
+ for (const segment of shellSegments(normalized)) {
27
+ const words = shellWords(segment);
28
+ const commandIndex = commandWordIndex(words);
29
+ if (commandIndex === -1) {
30
+ continue;
31
+ }
32
+ const name = basename(words[commandIndex] ?? "");
33
+ const args = words.slice(commandIndex + 1);
34
+
35
+ if (["shutdown", "reboot", "halt", "poweroff"].includes(name)) {
36
+ return dangerous(`system power command ${name}`);
37
+ }
38
+ if (name === "wipefs" || name.startsWith("mkfs.")) {
39
+ return dangerous(`block-device command ${name}`);
40
+ }
41
+ if (name === "dd" && args.some((word) => /^of=\/dev\/[^/]/.test(word))) {
42
+ return dangerous("dd writes directly to a device");
43
+ }
44
+ if ((name === "chmod" || name === "chown") && hasRecursiveFlag(args)) {
45
+ const operands = args.filter((word) => !word.startsWith("-"));
46
+ if (operands.at(-1) === "/") {
47
+ return dangerous(`${name} recursively targets the filesystem root`);
48
+ }
49
+ }
50
+ if (name === "rm" && hasRecursiveFlag(args) && hasForceFlag(args)) {
51
+ const operands = args.filter((word) => !word.startsWith("-"));
52
+ if (
53
+ operands.some((target) => isDestructiveRmTarget(target, context.workspaceRoot))
54
+ ) {
55
+ return dangerous("recursive forced removal targets a protected root");
56
+ }
57
+ }
58
+ }
59
+
60
+ return SAFE;
61
+ }
62
+
63
+ function dangerous(reason: string): BashRisk {
64
+ return Object.freeze({ dangerous: true, reason });
65
+ }
66
+
67
+ function shellSegments(command: string): string[] {
68
+ return command.split(/(?:&&|\|\||[;|\n])/);
69
+ }
70
+
71
+ function shellWords(segment: string): string[] {
72
+ return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g)?.map(unquote) ?? [];
73
+ }
74
+
75
+ function unquote(word: string): string {
76
+ if (
77
+ (word.startsWith('"') && word.endsWith('"')) ||
78
+ (word.startsWith("'") && word.endsWith("'"))
79
+ ) {
80
+ return word.slice(1, -1);
81
+ }
82
+ return word;
83
+ }
84
+
85
+ function commandWordIndex(words: readonly string[]): number {
86
+ let index = 0;
87
+ while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) {
88
+ index += 1;
89
+ }
90
+ if (basename(words[index] ?? "") === "sudo") {
91
+ index += 1;
92
+ while ((words[index] ?? "").startsWith("-")) {
93
+ index += 1;
94
+ }
95
+ }
96
+ return index < words.length ? index : -1;
97
+ }
98
+
99
+ function basename(word: string): string {
100
+ return word.slice(word.lastIndexOf("/") + 1);
101
+ }
102
+
103
+ function hasRecursiveFlag(args: readonly string[]): boolean {
104
+ return args.some((word) => /^-[^-]*[rR]/.test(word) || word === "--recursive");
105
+ }
106
+
107
+ function hasForceFlag(args: readonly string[]): boolean {
108
+ return args.some((word) => /^-[^-]*f/.test(word) || word === "--force");
109
+ }
110
+
111
+ function isDestructiveRmTarget(
112
+ target: string,
113
+ workspaceRoot: string | undefined,
114
+ ): boolean {
115
+ if (target === "/" || target === "/*" || target === "~" || target === "~/*") {
116
+ return true;
117
+ }
118
+ if (
119
+ target === "$HOME" ||
120
+ target === "${HOME}" ||
121
+ target === "$HOME/*" ||
122
+ target === "${HOME}/*"
123
+ ) {
124
+ return true;
125
+ }
126
+ if (workspaceRoot === undefined || !path.isAbsolute(target)) {
127
+ return false;
128
+ }
129
+ const normalizedTarget = path.resolve(target.replace(/\/\*$/, ""));
130
+ return normalizedTarget === path.resolve(workspaceRoot);
131
+ }
package/src/tools/bash.ts CHANGED
@@ -12,6 +12,7 @@ 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;
@@ -91,6 +92,36 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
91
92
  }
92
93
 
93
94
  const input = parsed.value;
95
+ const risk = classifyBashRisk(input.command, {
96
+ workspaceRoot: options.workspaceRoot,
97
+ });
98
+ if (risk.dangerous && context.confirmBashCommand !== undefined) {
99
+ const decision = await context.confirmBashCommand({
100
+ command: input.command,
101
+ reason: risk.reason,
102
+ });
103
+ throwIfTurnCancelled(context.signal);
104
+ if (decision === "deny") {
105
+ const suffix =
106
+ context.bashGuardSurface === "one-shot"
107
+ ? "Non-interactive mode cannot confirm; rerun with --yolo."
108
+ : "The user declined this command.";
109
+ return {
110
+ ok: false,
111
+ command: input.command,
112
+ taskId: "",
113
+ sessionId: call.sessionId,
114
+ status: "failed",
115
+ cwd: options.cwdState.cwd,
116
+ outputFilePath: "",
117
+ outputBytes: 0,
118
+ outputLines: 0,
119
+ preview: "",
120
+ truncated: false,
121
+ error: `Command denied: ${risk.reason}. ${suffix}`,
122
+ };
123
+ }
124
+ }
94
125
  const foregroundTimeoutMs = input.timeout ?? defaultTimeoutMs;
95
126
  throwIfTurnCancelled(context.signal);
96
127
  const task = await options.taskManager.start({
@@ -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
+ }
package/src/tools/edit.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import type { ToolCall } from "../agent/types";
3
4
  import { throwIfTurnCancelled } from "../agent/turn-cancellation";
4
5
  import { computeFilePatch } from "./file-diff";
5
6
  import { ensureParentDirectory } from "./ensure-parent-directory";
6
7
  import { sha256Bytes, sha256Text } from "./hash";
7
8
  import { resolveWorkspacePath } from "./path-safety";
9
+ import type { TurnUndoManager } from "./turn-undo-manager";
8
10
  import { defineToolExecutor } from "./types";
9
11
  import type {
10
12
  EditFileRawResult,
@@ -23,6 +25,7 @@ type EditArgs = {
23
25
  export type EditToolOptions = {
24
26
  workspaceRoot: string;
25
27
  snapshots: FileSnapshotStore;
28
+ undoManager?: TurnUndoManager;
26
29
  };
27
30
 
28
31
  export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
@@ -58,7 +61,7 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
58
61
  },
59
62
  async execute(
60
63
  args,
61
- _call,
64
+ call,
62
65
  context: ToolExecutionContext,
63
66
  ): Promise<EditFileRawResult> {
64
67
  throwIfTurnCancelled(context.signal);
@@ -102,6 +105,8 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
102
105
  target,
103
106
  newContent: input.new_string,
104
107
  snapshots: options.snapshots,
108
+ undoManager: options.undoManager,
109
+ call,
105
110
  signal: context.signal,
106
111
  });
107
112
  }
@@ -173,6 +178,9 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
173
178
  expectedSha256: target.sha256,
174
179
  readRequiredOnChange: true,
175
180
  snapshots: options.snapshots,
181
+ undoManager: options.undoManager,
182
+ call,
183
+ initialBefore: target,
176
184
  signal: context.signal,
177
185
  });
178
186
  },
@@ -219,6 +227,8 @@ async function writeEmptyTarget(input: {
219
227
  target: TargetFileState;
220
228
  newContent: string;
221
229
  snapshots: FileSnapshotStore;
230
+ undoManager?: TurnUndoManager;
231
+ call: ToolCall;
222
232
  signal: AbortSignal;
223
233
  }): Promise<EditFileRawResult> {
224
234
  if (input.target.exists && input.target.content.length > 0) {
@@ -242,6 +252,9 @@ async function writeEmptyTarget(input: {
242
252
  expectedSha256: input.target.exists ? input.target.sha256 : undefined,
243
253
  readRequiredOnChange: false,
244
254
  snapshots: input.snapshots,
255
+ undoManager: input.undoManager,
256
+ call: input.call,
257
+ initialBefore: input.target,
245
258
  signal: input.signal,
246
259
  });
247
260
  }
@@ -258,10 +271,14 @@ async function writeEditedContent(input: {
258
271
  expectedSha256?: string;
259
272
  readRequiredOnChange: boolean;
260
273
  snapshots: FileSnapshotStore;
274
+ undoManager?: TurnUndoManager;
275
+ call: ToolCall;
276
+ initialBefore: TargetFileState;
261
277
  signal: AbortSignal;
262
278
  }): Promise<EditFileRawResult> {
263
279
  throwIfTurnCancelled(input.signal);
264
280
 
281
+ let verifiedBefore = input.initialBefore;
265
282
  if (input.expectedSha256 !== undefined) {
266
283
  const currentState = await targetFileState(input.absolutePath);
267
284
  throwIfTurnCancelled(input.signal);
@@ -296,12 +313,27 @@ async function writeEditedContent(input: {
296
313
  : "File changed while Edit was being prepared. Retry Edit with the current file state.",
297
314
  };
298
315
  }
316
+ verifiedBefore = currentState;
299
317
  }
300
318
 
319
+ const undoCapture = await input.undoManager?.captureBeforeMutation({
320
+ turnId: input.call.turnId,
321
+ turnNumber: input.call.turnNumber,
322
+ absolutePath: input.absolutePath,
323
+ displayPath: input.filePath,
324
+ loadBefore: async () =>
325
+ verifiedBefore.exists
326
+ ? { state: "present", bytes: verifiedBefore.bytes }
327
+ : { state: "absent" },
328
+ });
329
+
301
330
  if (input.created) {
302
331
  try {
303
332
  await ensureParentDirectory(input.absolutePath);
304
333
  } catch (error) {
334
+ if (undoCapture !== undefined) {
335
+ await input.undoManager?.recordMutationFailure(undoCapture);
336
+ }
305
337
  return {
306
338
  ok: false,
307
339
  filePath: input.filePath,
@@ -312,14 +344,39 @@ async function writeEditedContent(input: {
312
344
  throwIfTurnCancelled(input.signal);
313
345
  }
314
346
 
315
- await writeFile(input.absolutePath, input.newContent, "utf8");
316
- const newSha256 = sha256Text(input.newContent);
317
- const writtenInfo = await stat(input.absolutePath);
318
- input.snapshots.set(input.absolutePath, {
319
- sha256: newSha256,
320
- mtimeMs: writtenInfo.mtimeMs,
321
- source: "edit",
322
- });
347
+ let newSha256: string;
348
+ try {
349
+ throwIfTurnCancelled(input.signal);
350
+ await writeFile(input.absolutePath, input.newContent, "utf8");
351
+ const expectedSha256 = sha256Text(input.newContent);
352
+ const written = await targetFileState(input.absolutePath);
353
+ if (!written.ok) {
354
+ throw new Error(`Failed to verify edited file: ${written.error}`);
355
+ }
356
+ if (!written.exists) {
357
+ throw new Error("Failed to verify edited file: File does not exist.");
358
+ }
359
+ if (written.sha256 !== expectedSha256) {
360
+ throw new Error("File changed while Edit was being verified.");
361
+ }
362
+ newSha256 = written.sha256;
363
+ if (undoCapture !== undefined) {
364
+ input.undoManager?.recordMutationResult(undoCapture, {
365
+ state: "present",
366
+ sha256: newSha256,
367
+ });
368
+ }
369
+ input.snapshots.set(input.absolutePath, {
370
+ sha256: newSha256,
371
+ mtimeMs: written.mtimeMs,
372
+ source: "edit",
373
+ });
374
+ } catch (error) {
375
+ if (undoCapture !== undefined) {
376
+ await input.undoManager?.recordMutationFailure(undoCapture);
377
+ }
378
+ throw error;
379
+ }
323
380
 
324
381
  const patch = computeFilePatch({
325
382
  filePath: input.filePath,
@@ -348,6 +405,7 @@ type TargetFileState =
348
405
  ok: true;
349
406
  exists: true;
350
407
  content: string;
408
+ bytes: Buffer;
351
409
  sha256: string;
352
410
  mtimeMs: number;
353
411
  };
@@ -371,6 +429,7 @@ async function targetFileState(
371
429
  ok: true,
372
430
  exists: true,
373
431
  content: bytes.toString("utf8"),
432
+ bytes,
374
433
  sha256: sha256Bytes(bytes),
375
434
  mtimeMs: currentInfo.mtimeMs,
376
435
  };
@@ -1,6 +1,7 @@
1
1
  import { createBashToolExecutor } from "./bash";
2
2
  import { ShellTaskManager } from "./bash-task";
3
3
  import { createCwdState } from "./cwd-state";
4
+ import { createDeleteToolExecutor } from "./delete";
4
5
  import { createEditToolExecutor } from "./edit";
5
6
  import { createGlobToolExecutor } from "./glob";
6
7
  import { createGrepToolExecutor } from "./grep";
@@ -13,6 +14,7 @@ import { createWebFetchToolExecutor } from "./web-fetch";
13
14
  import type { Refiner } from "./web-fetch/refiner";
14
15
  import { createWebSearchToolExecutor } from "./web-search";
15
16
  import { createWriteToolExecutor } from "./write";
17
+ import { TurnUndoManager } from "./turn-undo-manager";
16
18
  import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
17
19
  import type {
18
20
  RuntimeSessionContext,
@@ -62,7 +64,17 @@ export class ToolRegistry {
62
64
  }
63
65
 
64
66
  export class ToolRuntime {
65
- constructor(private readonly registry: ToolRegistry) {}
67
+ constructor(
68
+ private readonly registry: ToolRegistry,
69
+ private readonly bashGuard?: {
70
+ readonly surface: "tui" | "one-shot";
71
+ confirm(
72
+ call: ToolCall,
73
+ request: { command: string; reason: string },
74
+ signal: AbortSignal,
75
+ ): Promise<"allow" | "deny">;
76
+ },
77
+ ) {}
66
78
 
67
79
  async execute(call: ToolCall, context: ToolExecutionContext): Promise<ToolRawResult> {
68
80
  throwIfTurnCancelled(context.signal);
@@ -88,7 +100,16 @@ export class ToolRuntime {
88
100
  }
89
101
 
90
102
  try {
91
- return await tool.execute(call.args, call, context);
103
+ return await tool.execute(call.args, call, {
104
+ ...context,
105
+ ...(this.bashGuard === undefined
106
+ ? {}
107
+ : {
108
+ bashGuardSurface: this.bashGuard.surface,
109
+ confirmBashCommand: (request: { command: string; reason: string }) =>
110
+ this.bashGuard!.confirm(call, request, context.signal),
111
+ }),
112
+ });
92
113
  } catch (error) {
93
114
  if (context.signal.aborted) {
94
115
  throw cancellationError(context.signal, error);
@@ -113,6 +134,7 @@ export type DefaultTooling = {
113
134
  snapshots: FileSnapshotStore;
114
135
  bashState: BashToolingState;
115
136
  taskManager: ShellTaskManager;
137
+ turnUndoManager?: TurnUndoManager;
116
138
  dispose(reason?: SessionDisposeReason["type"]): Promise<void>;
117
139
  };
118
140
 
@@ -134,8 +156,20 @@ export function createDefaultTooling(options: {
134
156
  skillCoordinator?: SkillActivationCoordinator;
135
157
  toolingConfig?: PublicToolingConfig;
136
158
  memorySearch?: ToolExecutor;
159
+ enableTurnUndo?: boolean;
160
+ bashGuard?: {
161
+ readonly surface: "tui" | "one-shot";
162
+ confirm(
163
+ call: ToolCall,
164
+ request: { command: string; reason: string },
165
+ signal: AbortSignal,
166
+ ): Promise<"allow" | "deny">;
167
+ };
137
168
  }): DefaultTooling {
138
169
  const snapshots: FileSnapshotStore = new Map();
170
+ const turnUndoManager = options.enableTurnUndo
171
+ ? new TurnUndoManager({ snapshots })
172
+ : undefined;
139
173
  const registry = new ToolRegistry();
140
174
  const runtimeSession = options.runtimeSession;
141
175
  const toolingConfig = options.toolingConfig ?? DEFAULT_PUBLIC_TOOLING_CONFIG;
@@ -194,12 +228,21 @@ export function createDefaultTooling(options: {
194
228
  createWriteToolExecutor({
195
229
  workspaceRoot: options.workspaceRoot,
196
230
  snapshots,
231
+ ...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
197
232
  }),
198
233
  );
199
234
  registry.register(
200
235
  createEditToolExecutor({
201
236
  workspaceRoot: options.workspaceRoot,
202
237
  snapshots,
238
+ ...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
239
+ }),
240
+ );
241
+ registry.register(
242
+ createDeleteToolExecutor({
243
+ workspaceRoot: options.workspaceRoot,
244
+ snapshots,
245
+ ...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
203
246
  }),
204
247
  );
205
248
  registry.register(
@@ -232,9 +275,10 @@ export function createDefaultTooling(options: {
232
275
 
233
276
  return {
234
277
  registry,
235
- runtime: new ToolRuntime(registry),
278
+ runtime: new ToolRuntime(registry, options.bashGuard),
236
279
  snapshots,
237
280
  taskManager,
281
+ ...(turnUndoManager === undefined ? {} : { turnUndoManager }),
238
282
  bashState: {
239
283
  get cwd() {
240
284
  return cwdState.cwd;