agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Line-level diff computation, powered by the `diff` npm package.
3
+ *
4
+ * Exposes a unified `DiffResult` interface consumed by the diff renderer.
5
+ * Three entry points cover the main use cases:
6
+ *
7
+ * computeDiff — full-file diff (write_file, or when edit region can't be located)
8
+ * computeEditDiff — edit_file: locates the edit region, builds the new file, full diff
9
+ * computeInputDiff — fast preview: diffs only old_text vs new_text, no file I/O
10
+ */
11
+
12
+ import * as Diff from "diff";
13
+
14
+ // ── Types ────────────────────────────────────────────────────────────
15
+
16
+ export interface DiffLine {
17
+ type: "context" | "added" | "removed";
18
+ oldNo: number | null;
19
+ newNo: number | null;
20
+ text: string;
21
+ }
22
+
23
+ export interface DiffHunk {
24
+ lines: DiffLine[];
25
+ }
26
+
27
+ export interface DiffResult {
28
+ hunks: DiffHunk[];
29
+ added: number;
30
+ removed: number;
31
+ isIdentical: boolean;
32
+ isNewFile: boolean;
33
+ }
34
+
35
+ // ── Helpers ──────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Convert a `diff` library Change[] into our DiffLine[], tracking real
39
+ * old/new line numbers.
40
+ */
41
+ function changesToDiffLines(changes: Diff.Change[]): DiffLine[] {
42
+ const result: DiffLine[] = [];
43
+ let oldNo = 0;
44
+ let newNo = 0;
45
+
46
+ for (const change of changes) {
47
+ const lines = change.value.replace(/\n$/, "").split("\n");
48
+ for (const text of lines) {
49
+ if (change.added) {
50
+ newNo++;
51
+ result.push({ type: "added", oldNo: null, newNo, text });
52
+ } else if (change.removed) {
53
+ oldNo++;
54
+ result.push({ type: "removed", oldNo, newNo: null, text });
55
+ } else {
56
+ oldNo++;
57
+ newNo++;
58
+ result.push({ type: "context", oldNo, newNo, text });
59
+ }
60
+ }
61
+ }
62
+
63
+ return result;
64
+ }
65
+
66
+ /**
67
+ * Group raw DiffLines into hunks with `context` lines of surrounding context.
68
+ */
69
+ function groupHunks(lines: DiffLine[], ctx: number): DiffHunk[] {
70
+ const changeIdx: number[] = [];
71
+ for (let i = 0; i < lines.length; i++)
72
+ if (lines[i].type !== "context") changeIdx.push(i);
73
+
74
+ if (changeIdx.length === 0) return [];
75
+
76
+ const hunks: DiffHunk[] = [];
77
+ let start = Math.max(0, changeIdx[0]! - ctx);
78
+ let end = Math.min(lines.length - 1, changeIdx[0]! + ctx);
79
+
80
+ for (let k = 1; k < changeIdx.length; k++) {
81
+ const ns = Math.max(0, changeIdx[k]! - ctx);
82
+ const ne = Math.min(lines.length - 1, changeIdx[k]! + ctx);
83
+ if (ns <= end + 1) {
84
+ end = ne;
85
+ } else {
86
+ hunks.push({ lines: lines.slice(start, end + 1) });
87
+ start = ns;
88
+ end = ne;
89
+ }
90
+ }
91
+ hunks.push({ lines: lines.slice(start, end + 1) });
92
+ return hunks;
93
+ }
94
+
95
+ function countChanges(lines: DiffLine[]): { added: number; removed: number } {
96
+ let added = 0;
97
+ let removed = 0;
98
+ for (const l of lines) {
99
+ if (l.type === "added") added++;
100
+ else if (l.type === "removed") removed++;
101
+ }
102
+ return { added, removed };
103
+ }
104
+
105
+ // ── Public API ───────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Compute a line-level diff between old and new file content.
109
+ */
110
+ export function computeDiff(
111
+ oldText: string | null,
112
+ newText: string,
113
+ ): DiffResult {
114
+ // New file — everything is an addition
115
+ if (oldText === null) {
116
+ const lines = newText.split("\n");
117
+ return {
118
+ hunks: [
119
+ {
120
+ lines: lines.map((text, i) => ({
121
+ type: "added" as const,
122
+ oldNo: null,
123
+ newNo: i + 1,
124
+ text,
125
+ })),
126
+ },
127
+ ],
128
+ added: lines.length,
129
+ removed: 0,
130
+ isIdentical: false,
131
+ isNewFile: true,
132
+ };
133
+ }
134
+
135
+ // Identical — nothing to show
136
+ if (oldText === newText) {
137
+ return { hunks: [], added: 0, removed: 0, isIdentical: true, isNewFile: false };
138
+ }
139
+
140
+ const changes = Diff.diffLines(oldText, newText);
141
+ const raw = changesToDiffLines(changes);
142
+ const { added, removed } = countChanges(raw);
143
+
144
+ return {
145
+ hunks: groupHunks(raw, 3),
146
+ added,
147
+ removed,
148
+ isIdentical: false,
149
+ isNewFile: false,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Compute a diff for an edit operation where we know the old/new text.
155
+ * Locates the edit region(s) in the file, constructs the full new file,
156
+ * then diffs the whole thing so line numbers are file-relative.
157
+ */
158
+ export function computeEditDiff(
159
+ oldFileText: string,
160
+ editOld: string,
161
+ editNew: string,
162
+ replaceAll = false,
163
+ ): DiffResult {
164
+ const a = oldFileText.split("\n");
165
+ const editOldLines = editOld.split("\n");
166
+ const editNewLines = editNew.split("\n");
167
+
168
+ // Find all occurrences of editOld in the file
169
+ const regions: { start: number; end: number }[] = [];
170
+ if (replaceAll) {
171
+ let i = 0;
172
+ while (i <= a.length - editOldLines.length) {
173
+ let match = true;
174
+ for (let k = 0; k < editOldLines.length; k++) {
175
+ if (a[i + k] !== editOldLines[k]) { match = false; break; }
176
+ }
177
+ if (match) {
178
+ regions.push({ start: i, end: i + editOldLines.length });
179
+ i += editOldLines.length;
180
+ } else {
181
+ i++;
182
+ }
183
+ }
184
+ } else {
185
+ for (let i = 0; i <= a.length - editOldLines.length; i++) {
186
+ let match = true;
187
+ for (let k = 0; k < editOldLines.length; k++) {
188
+ if (a[i + k] !== editOldLines[k]) { match = false; break; }
189
+ }
190
+ if (match) {
191
+ regions.push({ start: i, end: i + editOldLines.length });
192
+ break;
193
+ }
194
+ }
195
+ }
196
+
197
+ // Build the full new file
198
+ let newFile: string[];
199
+ if (replaceAll && regions.length > 0) {
200
+ const parts: string[] = [];
201
+ let last = 0;
202
+ for (const r of regions) {
203
+ parts.push(...a.slice(last, r.start));
204
+ parts.push(...editNewLines);
205
+ last = r.end;
206
+ }
207
+ parts.push(...a.slice(last));
208
+ newFile = parts;
209
+ } else if (regions.length === 1) {
210
+ const r = regions[0]!;
211
+ newFile = [...a.slice(0, r.start), ...editNewLines, ...a.slice(r.end)];
212
+ } else {
213
+ // Couldn't locate edit — fall back to string replace + full diff
214
+ const newContent = replaceAll
215
+ ? oldFileText.split(editOld).join(editNew)
216
+ : oldFileText.replace(editOld, editNew);
217
+ return computeDiff(oldFileText, newContent);
218
+ }
219
+
220
+ return computeDiff(oldFileText, newFile.join("\n"));
221
+ }
222
+
223
+ /**
224
+ * Diff two edit strings directly — no file read needed.
225
+ * Line numbers are relative to the edit region, not the file.
226
+ * Use for permission prompt previews where speed matters more than
227
+ * exact file-relative line numbers.
228
+ */
229
+ export function computeInputDiff(
230
+ oldText: string,
231
+ newText: string,
232
+ ): DiffResult {
233
+ const changes = Diff.diffLines(oldText, newText);
234
+ const raw = changesToDiffLines(changes);
235
+ const { added, removed } = countChanges(raw);
236
+
237
+ return {
238
+ hunks: groupHunks(raw, 3),
239
+ added,
240
+ removed,
241
+ isIdentical: false,
242
+ isNewFile: false,
243
+ };
244
+ }
@@ -0,0 +1,305 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { stripAnsi } from "./ansi.js";
4
+
5
+ // Node reports a missing cwd as `spawn <binary> ENOENT` — disambiguate.
6
+ function explainSpawnError(err: NodeJS.ErrnoException, cwd: string): string {
7
+ if (err.code === "ENOENT" && !existsSync(cwd)) {
8
+ return `cwd no longer exists: ${cwd} (${err.message})`;
9
+ }
10
+ return err.message;
11
+ }
12
+
13
+ let cachedBashPath: string | null | undefined;
14
+
15
+ /** Resolve a usable bash binary, or null if none is on PATH.
16
+ * Unix: `/bin/bash` (canonical, present on every Linux/macOS install).
17
+ * Windows: probe via `where bash` so Git Bash users keep working. */
18
+ export function findBash(): string | null {
19
+ if (cachedBashPath !== undefined) return cachedBashPath;
20
+ if (process.platform !== "win32") {
21
+ cachedBashPath = "/bin/bash";
22
+ return cachedBashPath;
23
+ }
24
+ const r = spawnSync("where", ["bash"], { encoding: "utf-8" });
25
+ cachedBashPath = r.status === 0 ? r.stdout.split(/\r?\n/)[0]!.trim() || null : null;
26
+ return cachedBashPath;
27
+ }
28
+
29
+ const DEFAULT_TIMEOUT = 60_000;
30
+ const DEFAULT_MAX_OUTPUT = 256 * 1024; // 256KB
31
+
32
+ export interface ExecutorSession {
33
+ id: string;
34
+ command: string;
35
+ output: string; // accumulated, ANSI-stripped
36
+ exitCode: number | null;
37
+ done: boolean;
38
+ truncated: boolean;
39
+ /** True when the binary couldn't be launched (ENOENT, EACCES). Lets callers
40
+ * distinguish "tool missing" from "tool ran and exited with -1". */
41
+ spawnFailed: boolean;
42
+ process: ChildProcess | null;
43
+ resolve?: () => void;
44
+ }
45
+
46
+
47
+ /**
48
+ * Spawn a command in an isolated child process with piped I/O.
49
+ * Does NOT use the user's PTY — completely separate process.
50
+ */
51
+ export function executeCommand(opts: {
52
+ command: string;
53
+ cwd: string;
54
+ env?: Record<string, string>;
55
+ timeout?: number;
56
+ maxOutputBytes?: number;
57
+ onOutput?: (chunk: string) => void;
58
+ }): { session: ExecutorSession; done: Promise<void> } {
59
+ const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
60
+ const maxOutput = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
61
+
62
+ const session: ExecutorSession = {
63
+ id: "",
64
+ command: opts.command,
65
+ output: "",
66
+ exitCode: null,
67
+ done: false,
68
+ truncated: false,
69
+ spawnFailed: false,
70
+ process: null,
71
+ };
72
+
73
+ const done = new Promise<void>((resolve) => {
74
+ session.resolve = resolve;
75
+ });
76
+
77
+ // Build env — filter undefined values
78
+ const env: Record<string, string> = {};
79
+ const source = opts.env ?? process.env;
80
+ for (const [k, v] of Object.entries(source)) {
81
+ if (v !== undefined) env[k] = v;
82
+ }
83
+
84
+ const bashPath = findBash();
85
+ let child: ChildProcess;
86
+ try {
87
+ if (!bashPath) throw new Error("bash not found on PATH");
88
+ child = spawn(bashPath, ["-c", opts.command], {
89
+ stdio: ["ignore", "pipe", "pipe"],
90
+ cwd: opts.cwd,
91
+ env,
92
+ detached: process.platform !== "win32",
93
+ windowsHide: true,
94
+ });
95
+ } catch (err) {
96
+ session.exitCode = -1;
97
+ session.spawnFailed = true;
98
+ const msg = err instanceof Error
99
+ ? explainSpawnError(err as NodeJS.ErrnoException, opts.cwd)
100
+ : String(err);
101
+ session.output = `Failed to spawn: ${msg}`;
102
+ session.done = true;
103
+ session.resolve?.();
104
+ return { session, done };
105
+ }
106
+
107
+ session.process = child;
108
+
109
+ const handleData = (data: Buffer) => {
110
+ const raw = data.toString("utf-8");
111
+ const clean = stripAnsi(raw);
112
+
113
+ // Accumulate cleaned output for the agent
114
+ session.output += clean;
115
+
116
+ // Enforce output cap — truncate from beginning, keep tail
117
+ if (session.output.length > maxOutput) {
118
+ session.output = session.output.slice(-maxOutput);
119
+ session.truncated = true;
120
+ }
121
+
122
+ // Real-time streaming callback
123
+ opts.onOutput?.(raw);
124
+ };
125
+
126
+ child.stdout?.on("data", handleData);
127
+ child.stderr?.on("data", handleData);
128
+
129
+ let cancelKill: (() => void) | undefined;
130
+ const timer = setTimeout(() => {
131
+ if (!session.done) {
132
+ cancelKill = killSession(session);
133
+ }
134
+ }, timeout);
135
+
136
+ child.on("exit", (code, signal) => {
137
+ clearTimeout(timer);
138
+ cancelKill?.();
139
+ session.exitCode = code ?? (signal ? -1 : null);
140
+ session.done = true;
141
+ session.process = null;
142
+ session.resolve?.();
143
+ });
144
+
145
+ child.on("error", (err) => {
146
+ clearTimeout(timer);
147
+ cancelKill?.();
148
+ if (!session.done) {
149
+ session.exitCode = -1;
150
+ const code = (err as NodeJS.ErrnoException).code;
151
+ if (code === "ENOENT" || code === "EACCES") session.spawnFailed = true;
152
+ session.output += `\nProcess error: ${explainSpawnError(err as NodeJS.ErrnoException, opts.cwd)}`;
153
+ session.done = true;
154
+ session.process = null;
155
+ session.resolve?.();
156
+ }
157
+ });
158
+
159
+ return { session, done };
160
+ }
161
+
162
+ /**
163
+ * Spawn a binary directly (no shell). Use for invoking known tools like `rg`
164
+ * with structured args — avoids shell-quoting bugs and works on platforms
165
+ * without /bin/bash.
166
+ */
167
+ export function executeArgv(opts: {
168
+ file: string;
169
+ args: string[];
170
+ cwd: string;
171
+ env?: Record<string, string>;
172
+ timeout?: number;
173
+ maxOutputBytes?: number;
174
+ onOutput?: (chunk: string) => void;
175
+ }): { session: ExecutorSession; done: Promise<void> } {
176
+ const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
177
+ const maxOutput = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
178
+
179
+ const session: ExecutorSession = {
180
+ id: "",
181
+ command: `${opts.file} ${opts.args.join(" ")}`,
182
+ output: "",
183
+ exitCode: null,
184
+ done: false,
185
+ truncated: false,
186
+ spawnFailed: false,
187
+ process: null,
188
+ };
189
+
190
+ const done = new Promise<void>((resolve) => {
191
+ session.resolve = resolve;
192
+ });
193
+
194
+ const env: Record<string, string> = {};
195
+ const source = opts.env ?? process.env;
196
+ for (const [k, v] of Object.entries(source)) {
197
+ if (v !== undefined) env[k] = v;
198
+ }
199
+
200
+ let child: ChildProcess;
201
+ try {
202
+ child = spawn(opts.file, opts.args, {
203
+ stdio: ["ignore", "pipe", "pipe"],
204
+ cwd: opts.cwd,
205
+ env,
206
+ });
207
+ } catch (err) {
208
+ session.exitCode = -1;
209
+ session.spawnFailed = true;
210
+ const msg = err instanceof Error
211
+ ? explainSpawnError(err as NodeJS.ErrnoException, opts.cwd)
212
+ : String(err);
213
+ session.output = `Failed to spawn ${opts.file}: ${msg}`;
214
+ session.done = true;
215
+ session.resolve?.();
216
+ return { session, done };
217
+ }
218
+
219
+ session.process = child;
220
+
221
+ const handleData = (data: Buffer) => {
222
+ const raw = data.toString("utf-8");
223
+ const clean = stripAnsi(raw);
224
+ session.output += clean;
225
+ if (session.output.length > maxOutput) {
226
+ session.output = session.output.slice(-maxOutput);
227
+ session.truncated = true;
228
+ }
229
+ opts.onOutput?.(raw);
230
+ };
231
+
232
+ child.stdout?.on("data", handleData);
233
+ child.stderr?.on("data", handleData);
234
+
235
+ const timer = setTimeout(() => {
236
+ if (!session.done && session.process) {
237
+ try { session.process.kill("SIGTERM"); } catch {}
238
+ setTimeout(() => {
239
+ if (!session.done && session.process) {
240
+ try { session.process.kill("SIGKILL"); } catch {}
241
+ }
242
+ }, 5000).unref();
243
+ }
244
+ }, timeout);
245
+
246
+ child.on("exit", (code, signal) => {
247
+ clearTimeout(timer);
248
+ session.exitCode = code ?? (signal ? -1 : null);
249
+ session.done = true;
250
+ session.process = null;
251
+ session.resolve?.();
252
+ });
253
+
254
+ child.on("error", (err) => {
255
+ clearTimeout(timer);
256
+ if (!session.done) {
257
+ session.exitCode = -1;
258
+ const code = (err as NodeJS.ErrnoException).code;
259
+ if (code === "ENOENT" || code === "EACCES") session.spawnFailed = true;
260
+ session.output += `\nProcess error: ${explainSpawnError(err as NodeJS.ErrnoException, opts.cwd)}`;
261
+ session.done = true;
262
+ session.process = null;
263
+ session.resolve?.();
264
+ }
265
+ });
266
+
267
+ return { session, done };
268
+ }
269
+
270
+ /**
271
+ * Kill a running session's process group: SIGTERM, then SIGKILL after 5s.
272
+ * Returns a cleanup that cancels the pending SIGKILL — callers should invoke
273
+ * it once the process has exited.
274
+ */
275
+ export function killSession(session: ExecutorSession): () => void {
276
+ const proc = session.process;
277
+ if (!proc || !proc.pid) return () => {};
278
+
279
+ // Try process-group kill first (works for executeCommand's detached bash
280
+ // children on Unix); fall back to direct kill (executeArgv's non-detached
281
+ // spawn, and Windows where negative pids aren't supported).
282
+ if (process.platform !== "win32") {
283
+ try { process.kill(-proc.pid, "SIGTERM"); } catch {}
284
+ }
285
+ try { proc.kill("SIGTERM"); } catch {}
286
+
287
+ let settled = false;
288
+ const fallback = setTimeout(() => {
289
+ if (!settled && !session.done && proc.pid) {
290
+ if (process.platform !== "win32") {
291
+ try { process.kill(-proc.pid, "SIGKILL"); } catch {}
292
+ }
293
+ try { proc.kill("SIGKILL"); } catch {}
294
+ }
295
+ }, 5000);
296
+
297
+ fallback.unref();
298
+
299
+ return () => {
300
+ if (!settled) {
301
+ settled = true;
302
+ clearTimeout(fallback);
303
+ }
304
+ };
305
+ }
@@ -0,0 +1,110 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ export interface FileChange {
5
+ path: string;
6
+ relPath: string;
7
+ before: string;
8
+ after: string;
9
+ }
10
+
11
+ const SKIP_DIRS = new Set([
12
+ ".git", "node_modules", "dist", "build", ".next",
13
+ "__pycache__", ".venv", "vendor", ".cache", ".turbo",
14
+ ]);
15
+ const MAX_FILES = 200;
16
+ const MAX_FILE_SIZE = 100_000; // 100 KB
17
+
18
+ /**
19
+ * Snapshots the working directory before an agent prompt so that
20
+ * file modifications made by **any** method (ACP writeTextFile,
21
+ * the agent's own edit tools, shell commands, etc.) can be detected
22
+ * and shown as an interactive diff preview.
23
+ */
24
+ export class FileWatcher {
25
+ private cwd: string;
26
+ private baseline = new Map<string, string>();
27
+
28
+ constructor(cwd: string) {
29
+ this.cwd = cwd;
30
+ }
31
+
32
+ /**
33
+ * Recursively snapshot all text files in the working directory.
34
+ * Skips common non-source directories, binary files, and files
35
+ * exceeding MAX_FILE_SIZE. Capped at MAX_FILES entries.
36
+ */
37
+ async snapshot(): Promise<void> {
38
+ this.baseline.clear();
39
+ let count = 0;
40
+
41
+ const walk = async (dir: string) => {
42
+ if (count >= MAX_FILES) return;
43
+ let entries;
44
+ try {
45
+ entries = await fs.readdir(dir, { withFileTypes: true });
46
+ } catch {
47
+ return;
48
+ }
49
+ for (const entry of entries) {
50
+ if (count >= MAX_FILES) return;
51
+ const full = path.join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ if (!SKIP_DIRS.has(entry.name)) await walk(full);
54
+ } else if (entry.isFile()) {
55
+ try {
56
+ const stat = await fs.stat(full);
57
+ if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
58
+ const content = await fs.readFile(full, "utf-8");
59
+ this.baseline.set(full, content);
60
+ count++;
61
+ } catch {
62
+ // Skip binary / unreadable files
63
+ }
64
+ }
65
+ }
66
+ };
67
+
68
+ await walk(this.cwd);
69
+ }
70
+
71
+ /** Update baseline after a write is approved (avoids double-reporting). */
72
+ approve(absPath: string, content: string): void {
73
+ this.baseline.set(absPath, content);
74
+ }
75
+
76
+ /** Detect all tracked files whose on-disk content differs from baseline. */
77
+ async detectChanges(): Promise<FileChange[]> {
78
+ const changes: FileChange[] = [];
79
+ for (const [absPath, baseline] of this.baseline) {
80
+ let after: string;
81
+ try {
82
+ after = await fs.readFile(absPath, "utf-8");
83
+ } catch {
84
+ continue;
85
+ }
86
+ if (baseline !== after) {
87
+ changes.push({
88
+ path: absPath,
89
+ relPath: path.relative(this.cwd, absPath),
90
+ before: baseline,
91
+ after,
92
+ });
93
+ }
94
+ }
95
+ return changes;
96
+ }
97
+
98
+ /** Revert a file to its baseline content. */
99
+ async revert(absPath: string): Promise<void> {
100
+ const baseline = this.baseline.get(absPath);
101
+ if (baseline !== undefined) {
102
+ await fs.writeFile(absPath, baseline, "utf-8");
103
+ }
104
+ }
105
+
106
+ /** Clear all tracking state. */
107
+ reset(): void {
108
+ this.baseline.clear();
109
+ }
110
+ }