jeo-code 0.7.31 → 0.7.33

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 (39) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.ja.md +4 -2
  3. package/README.ko.md +4 -2
  4. package/README.md +4 -2
  5. package/README.zh.md +4 -2
  6. package/package.json +4 -4
  7. package/scripts/install.sh +19 -0
  8. package/scripts/uninstall.sh +2 -2
  9. package/src/agent/AGENTS.md +15 -0
  10. package/src/agent/ast-edit-tool.ts +123 -0
  11. package/src/agent/ast-file-scan.ts +62 -0
  12. package/src/agent/ast-grep-tool.ts +107 -0
  13. package/src/agent/ast-match.ts +241 -0
  14. package/src/agent/browser-session.ts +104 -0
  15. package/src/agent/browser-tab.ts +145 -0
  16. package/src/agent/browser-tool.ts +188 -0
  17. package/src/agent/debug-session.ts +435 -0
  18. package/src/agent/debug-tool.ts +89 -0
  19. package/src/agent/engine.ts +72 -12
  20. package/src/agent/goal-tool.ts +57 -0
  21. package/src/agent/irc-tool.ts +71 -0
  22. package/src/agent/job-registry.ts +163 -0
  23. package/src/agent/job-tool.ts +106 -0
  24. package/src/agent/lsp-rename-tool.ts +108 -0
  25. package/src/agent/lsp-tool.ts +177 -0
  26. package/src/agent/session.ts +27 -0
  27. package/src/agent/subagent-registry.ts +40 -4
  28. package/src/agent/subagent-tool.ts +27 -7
  29. package/src/agent/subagents.ts +1 -1
  30. package/src/agent/task-tool.ts +27 -5
  31. package/src/agent/tools.ts +68 -1
  32. package/src/agent/ts-language-service.ts +173 -0
  33. package/src/commands/launch/agents-slash.ts +489 -0
  34. package/src/commands/launch/flags.ts +27 -9
  35. package/src/commands/launch/model-slash.ts +290 -0
  36. package/src/commands/launch/session-slash.ts +305 -0
  37. package/src/commands/launch/slash-handlers.ts +6 -0
  38. package/src/commands/launch/system-slash.ts +81 -0
  39. package/src/commands/launch.ts +208 -749
@@ -0,0 +1,435 @@
1
+ /**
2
+ * In-process CDP (V8 Inspector Protocol) client driving a spawned `node --inspect-brk`
3
+ * process (gjc `debug` parity, Node.js JavaScript/TypeScript ONLY).
4
+ *
5
+ * Scope, stated honestly:
6
+ * - Targets `node` specifically. Bun's own inspector speaks the WebKit Inspector
7
+ * Protocol dialect (Bun docs: "Bun speaks the WebKit Inspector Protocol"), and
8
+ * empirical testing here showed its `Debugger.paused`/resume sequencing does NOT
9
+ * behave like Node's — so Bun is NOT supported rather than silently half-working.
10
+ * - No DAP wire protocol, no other languages, no remote/attach mode — this speaks
11
+ * the underlying V8 protocol directly against a locally-spawned `node` process.
12
+ * - One debug session at a time, matching the tool's "one active session" model.
13
+ * `launch()` terminates any previous session first.
14
+ *
15
+ * Flow: `launch()` spawns `node --inspect-brk=0 <program> <args>`, connects over
16
+ * WebSocket, enables the Debugger/Runtime domains, and resumes through Node's own
17
+ * "Break on start" pause via `Runtime.runIfWaitingForDebugger` — the session is left
18
+ * PAUSED there so the caller can set breakpoints before the first line of real user
19
+ * code runs, then call `continue`.
20
+ */
21
+ import * as path from "node:path";
22
+
23
+ interface CdpLocation {
24
+ scriptId: string;
25
+ lineNumber: number;
26
+ columnNumber?: number;
27
+ }
28
+
29
+ interface CdpScope {
30
+ type: string;
31
+ object: { objectId?: string; description?: string };
32
+ }
33
+
34
+ interface CdpCallFrame {
35
+ callFrameId: string;
36
+ functionName: string;
37
+ location: CdpLocation;
38
+ scopeChain: CdpScope[];
39
+ }
40
+
41
+ interface PausedState {
42
+ callFrames: CdpCallFrame[];
43
+ reason: string;
44
+ }
45
+
46
+ /** Outcome of waiting for the next debug-relevant event: a real pause, the process
47
+ * actually exiting, or the script's execution context finishing while Node keeps
48
+ * the (now idle) process alive waiting for the debugger — see the
49
+ * `Runtime.executionContextDestroyed` handling in `handleMessage`. */
50
+ type WaitResult = PausedState | { exited: true; code: number | null } | { finished: true };
51
+
52
+ interface BreakpointRecord {
53
+ id: string;
54
+ file: string;
55
+ line: number;
56
+ condition?: string;
57
+ }
58
+
59
+ function escapeRegex(s: string): string {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ }
62
+
63
+ const CONNECT_TIMEOUT_MS = 10_000;
64
+ const PAUSE_TIMEOUT_MS = 15_000;
65
+ const MAX_OUTPUT_CHARS = 20_000;
66
+
67
+ export class DebugSession {
68
+ private proc: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined;
69
+ private ws: WebSocket | undefined;
70
+ private nextId = 1;
71
+ private pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
72
+ private paused: PausedState | undefined;
73
+ private exitCode: number | null | undefined;
74
+ private outputBuf = "";
75
+ private scriptUrls = new Map<string, string>(); // scriptId -> url
76
+ private breakpoints = new Map<string, BreakpointRecord>(); // cdp breakpointId -> record
77
+ private pausedWaiters: Array<(p: WaitResult) => void> = [];
78
+
79
+ get isActive(): boolean {
80
+ return !!this.proc && this.exitCode === undefined;
81
+ }
82
+
83
+ get isPaused(): boolean {
84
+ return !!this.paused;
85
+ }
86
+
87
+ private appendOutput(chunk: string): void {
88
+ this.outputBuf += chunk;
89
+ if (this.outputBuf.length > MAX_OUTPUT_CHARS) {
90
+ this.outputBuf = "…[truncated]…\n" + this.outputBuf.slice(this.outputBuf.length - MAX_OUTPUT_CHARS);
91
+ }
92
+ }
93
+
94
+ output(): string {
95
+ return this.outputBuf;
96
+ }
97
+
98
+ private async drain(stream: ReadableStream<Uint8Array>): Promise<void> {
99
+ const reader = stream.getReader();
100
+ const decoder = new TextDecoder();
101
+ try {
102
+ while (true) {
103
+ const { value, done } = await reader.read();
104
+ if (done) break;
105
+ this.appendOutput(decoder.decode(value, { stream: true }));
106
+ }
107
+ } catch {
108
+ // stream torn down on terminate — nothing further to drain
109
+ }
110
+ }
111
+
112
+ private send(method: string, params: Record<string, any> = {}): Promise<any> {
113
+ if (!this.ws) return Promise.reject(new Error("No active debug session."));
114
+ const id = this.nextId++;
115
+ return new Promise((resolve, reject) => {
116
+ this.pending.set(id, { resolve, reject });
117
+ this.ws!.send(JSON.stringify({ id, method, params }));
118
+ });
119
+ }
120
+
121
+ private handleMessage(raw: string): void {
122
+ let msg: any;
123
+ try {
124
+ msg = JSON.parse(raw);
125
+ } catch {
126
+ return;
127
+ }
128
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
129
+ const p = this.pending.get(msg.id)!;
130
+ this.pending.delete(msg.id);
131
+ if (msg.error) p.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
132
+ else p.resolve(msg.result);
133
+ return;
134
+ }
135
+ if (msg.method === "Debugger.scriptParsed") {
136
+ this.scriptUrls.set(msg.params.scriptId, msg.params.url);
137
+ } else if (msg.method === "Debugger.paused") {
138
+ this.paused = { callFrames: msg.params.callFrames, reason: msg.params.reason };
139
+ const waiters = this.pausedWaiters.splice(0);
140
+ for (const w of waiters) w(this.paused);
141
+ } else if (msg.method === "Debugger.resumed") {
142
+ this.paused = undefined;
143
+ } else if (msg.method === "Debugger.breakpointResolved") {
144
+ const rec = this.breakpoints.get(msg.params.breakpointId);
145
+ if (rec) this.scriptUrls.set(msg.params.location.scriptId, this.scriptUrls.get(msg.params.location.scriptId) ?? rec.file);
146
+ } else if (msg.method === "Runtime.executionContextDestroyed") {
147
+ // Node keeps the process alive after the script finishes while a debugger is
148
+ // attached (it waits to be told to exit) — proc.exited would hang forever, so
149
+ // this event is the real "the script is done" signal. Settle any waiter with
150
+ // `finished`, then kill the now-idle process ourselves; nothing further to debug.
151
+ const waiters = this.pausedWaiters.splice(0);
152
+ for (const w of waiters) w({ finished: true });
153
+ void this.terminate();
154
+ }
155
+ }
156
+
157
+ /** Resolve on the next `Debugger.paused`, OR immediately if the process has already
158
+ * exited (so a `continue`/`step` after the last breakpoint doesn't hang forever). */
159
+ private waitForPausedOrExit(): Promise<WaitResult> {
160
+ if (this.exitCode !== undefined) return Promise.resolve({ exited: true, code: this.exitCode });
161
+ return new Promise((resolve) => {
162
+ let done = false;
163
+ const settle = (v: WaitResult) => {
164
+ if (done) return;
165
+ done = true;
166
+ resolve(v);
167
+ };
168
+ this.pausedWaiters.push(settle);
169
+ this.proc?.exited.then((code) => settle({ exited: true, code }));
170
+ setTimeout(() => settle({ exited: true, code: null }), PAUSE_TIMEOUT_MS);
171
+ });
172
+ }
173
+
174
+ async launch(program: string, args: string[], cwd: string): Promise<{ success: boolean; output: string; error?: string }> {
175
+ await this.terminate();
176
+
177
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
178
+ try {
179
+ proc = Bun.spawn(["node", "--inspect-brk=0", program, ...args], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
180
+ } catch (err: any) {
181
+ return { success: false, output: "", error: `Failed to spawn 'node': ${err.message}` };
182
+ }
183
+ this.proc = proc;
184
+ this.exitCode = undefined;
185
+ this.outputBuf = "";
186
+ this.scriptUrls.clear();
187
+ this.breakpoints.clear();
188
+ this.paused = undefined;
189
+ void proc.exited.then((code) => { if (this.proc === proc) this.exitCode = code; });
190
+ void this.drain(proc.stdout);
191
+
192
+ const url = await this.waitForInspectorUrl(proc.stderr);
193
+ if (!url) {
194
+ await this.terminate();
195
+ return { success: false, output: "", error: "node did not print an inspector WebSocket URL in time — is 'node' installed and on PATH?" };
196
+ }
197
+
198
+ try {
199
+ const ws = new WebSocket(url);
200
+ this.ws = ws;
201
+ // Guard against a STALE socket delivering buffered messages after a newer
202
+ // session has replaced `this.ws` (e.g. a killed process's socket flushing its
203
+ // last frames concurrently with the next `launch()`) — without this, a
204
+ // straggler event would mutate the singleton's live session state from a dead
205
+ // session's listener closure.
206
+ ws.addEventListener("message", (ev) => { if (this.ws === ws) this.handleMessage(ev.data as string); });
207
+ await new Promise<void>((resolve, reject) => {
208
+ const t = setTimeout(() => reject(new Error("Timed out connecting to the inspector WebSocket.")), CONNECT_TIMEOUT_MS);
209
+ ws.addEventListener("open", () => { clearTimeout(t); resolve(); }, { once: true });
210
+ ws.addEventListener("error", () => { clearTimeout(t); reject(new Error("Inspector WebSocket connection failed.")); }, { once: true });
211
+ });
212
+ await this.send("Debugger.enable");
213
+ await this.send("Runtime.enable");
214
+ await this.send("Runtime.runIfWaitingForDebugger");
215
+ } catch (err: any) {
216
+ await this.terminate();
217
+ return { success: false, output: "", error: err.message };
218
+ }
219
+
220
+ const settled = await this.waitForPausedOrExit();
221
+ if ("exited" in settled) {
222
+ return { success: false, output: "", error: `Program exited (code ${settled.code}) before an initial breakpoint could be established.` };
223
+ }
224
+ if ("finished" in settled) {
225
+ return { success: false, output: "", error: "The program finished before an initial breakpoint could be established." };
226
+ }
227
+ return {
228
+ success: true,
229
+ output: `Launched 'node ${program} ${args.join(" ")}'. Paused at program start (${settled.reason}). ` +
230
+ `Set breakpoints, then 'continue' to run.`,
231
+ };
232
+ }
233
+
234
+ private async waitForInspectorUrl(stderr: ReadableStream<Uint8Array>): Promise<string | undefined> {
235
+ const reader = stderr.getReader();
236
+ const decoder = new TextDecoder();
237
+ let buf = "";
238
+ const deadline = Date.now() + CONNECT_TIMEOUT_MS;
239
+ try {
240
+ while (Date.now() < deadline) {
241
+ const { value, done } = await Promise.race([
242
+ reader.read(),
243
+ new Promise<{ value: undefined; done: false }>((r) => setTimeout(() => r({ value: undefined, done: false }), 200)),
244
+ ]);
245
+ if (done) break;
246
+ if (value) {
247
+ const text = decoder.decode(value, { stream: true });
248
+ buf += text;
249
+ this.appendOutput(text);
250
+ const m = /ws:\/\/\S+/.exec(buf);
251
+ if (m) return m[0];
252
+ }
253
+ }
254
+ } finally {
255
+ reader.releaseLock();
256
+ // Keep draining the rest of stderr into captured output after url discovery.
257
+ void this.drain(stderr).catch(() => {});
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ async setBreakpoint(file: string, line1: number, cwd: string, condition?: string): Promise<{ success: boolean; output: string; error?: string }> {
263
+ if (!this.isActive) return { success: false, output: "", error: "No active debug session. Launch one first." };
264
+ const absPath = path.resolve(cwd, file);
265
+ try {
266
+ const res = await this.send("Debugger.setBreakpointByUrl", {
267
+ lineNumber: line1 - 1,
268
+ urlRegex: escapeRegex(absPath),
269
+ condition,
270
+ });
271
+ this.breakpoints.set(res.breakpointId, { id: res.breakpointId, file: absPath, line: line1, condition });
272
+ const resolvedNote = res.locations?.length ? "" : " (not yet resolved — script not loaded yet, will resolve once it is)";
273
+ return { success: true, output: `Breakpoint ${res.breakpointId} set at ${file}:${line1}${resolvedNote}.` };
274
+ } catch (err: any) {
275
+ return { success: false, output: "", error: err.message };
276
+ }
277
+ }
278
+
279
+ async removeBreakpoint(id: string): Promise<{ success: boolean; output: string; error?: string }> {
280
+ if (!this.isActive) return { success: false, output: "", error: "No active debug session." };
281
+ if (!this.breakpoints.has(id)) return { success: false, output: "", error: `Unknown breakpoint id '${id}'.` };
282
+ try {
283
+ await this.send("Debugger.removeBreakpoint", { breakpointId: id });
284
+ this.breakpoints.delete(id);
285
+ return { success: true, output: `Breakpoint ${id} removed.` };
286
+ } catch (err: any) {
287
+ return { success: false, output: "", error: err.message };
288
+ }
289
+ }
290
+
291
+ private async resumeLike(method: string, label: string): Promise<{ success: boolean; output: string; error?: string }> {
292
+ if (!this.isActive) return { success: false, output: "", error: "No active debug session." };
293
+ if (!this.isPaused) return { success: false, output: "", error: `Cannot '${label}': the session is not paused.` };
294
+ try {
295
+ await this.send(method);
296
+ } catch (err: any) {
297
+ return { success: false, output: "", error: err.message };
298
+ }
299
+ const settled = await this.waitForPausedOrExit();
300
+ if ("exited" in settled) {
301
+ return { success: true, output: `Program exited (code ${settled.code}).` };
302
+ }
303
+ if ("finished" in settled) {
304
+ return { success: true, output: "Program finished (execution completed)." };
305
+ }
306
+ return { success: true, output: this.formatPaused(settled) };
307
+ }
308
+
309
+ continue(): Promise<{ success: boolean; output: string; error?: string }> {
310
+ return this.resumeLike("Debugger.resume", "continue");
311
+ }
312
+
313
+ stepOver(): Promise<{ success: boolean; output: string; error?: string }> {
314
+ return this.resumeLike("Debugger.stepOver", "step_over");
315
+ }
316
+
317
+ stepIn(): Promise<{ success: boolean; output: string; error?: string }> {
318
+ return this.resumeLike("Debugger.stepInto", "step_in");
319
+ }
320
+
321
+ stepOut(): Promise<{ success: boolean; output: string; error?: string }> {
322
+ return this.resumeLike("Debugger.stepOut", "step_out");
323
+ }
324
+
325
+ async pause(): Promise<{ success: boolean; output: string; error?: string }> {
326
+ if (!this.isActive) return { success: false, output: "", error: "No active debug session." };
327
+ if (this.isPaused) return { success: true, output: this.formatPaused(this.paused!) };
328
+ try {
329
+ await this.send("Debugger.pause");
330
+ } catch (err: any) {
331
+ return { success: false, output: "", error: err.message };
332
+ }
333
+ const settled = await this.waitForPausedOrExit();
334
+ if ("exited" in settled) return { success: true, output: `Program exited (code ${settled.code}).` };
335
+ if ("finished" in settled) return { success: true, output: "Program finished (execution completed)." };
336
+ return { success: true, output: this.formatPaused(settled) };
337
+ }
338
+
339
+ async evaluate(expression: string, frameIndex = 0): Promise<{ success: boolean; output: string; error?: string }> {
340
+ if (!this.isActive) return { success: false, output: "", error: "No active debug session." };
341
+ try {
342
+ let result: any;
343
+ if (this.isPaused) {
344
+ const frame = this.paused!.callFrames[frameIndex];
345
+ if (!frame) return { success: false, output: "", error: `No stack frame at index ${frameIndex}.` };
346
+ result = await this.send("Debugger.evaluateOnCallFrame", { callFrameId: frame.callFrameId, expression });
347
+ } else {
348
+ result = await this.send("Runtime.evaluate", { expression });
349
+ }
350
+ if (result.exceptionDetails) {
351
+ return { success: false, output: "", error: this.describeRemoteObject(result.result) };
352
+ }
353
+ return { success: true, output: this.describeRemoteObject(result.result) };
354
+ } catch (err: any) {
355
+ return { success: false, output: "", error: err.message };
356
+ }
357
+ }
358
+
359
+ private describeRemoteObject(obj: any): string {
360
+ if (!obj) return "undefined";
361
+ if (obj.type === "object" && obj.subtype === "error") return obj.description ?? "Error";
362
+ if (obj.value !== undefined) return typeof obj.value === "string" ? obj.value : JSON.stringify(obj.value);
363
+ return obj.description ?? obj.className ?? obj.type ?? "undefined";
364
+ }
365
+
366
+ private formatPaused(p: PausedState): string {
367
+ const lines = [`Paused (${p.reason}):`];
368
+ p.callFrames.slice(0, 10).forEach((f, i) => lines.push(` #${i} ${this.frameLabel(f)}`));
369
+ return lines.join("\n");
370
+ }
371
+
372
+ private frameLabel(f: CdpCallFrame): string {
373
+ const url = this.scriptUrls.get(f.location.scriptId) ?? f.location.scriptId;
374
+ return `${f.functionName || "(anonymous)"} (${url}:${f.location.lineNumber + 1})`;
375
+ }
376
+
377
+ stackTrace(): { success: boolean; output: string; error?: string } {
378
+ if (!this.isActive) return { success: false, output: "", error: "No active debug session." };
379
+ if (!this.isPaused) return { success: false, output: "", error: "The session is not paused." };
380
+ return { success: true, output: this.formatPaused(this.paused!) };
381
+ }
382
+
383
+ scopes(frameIndex = 0): { success: boolean; output: string; error?: string; scopes?: { type: string; objectId?: string }[] } {
384
+ if (!this.isActive) return { success: false, output: "", error: "No active debug session." };
385
+ if (!this.isPaused) return { success: false, output: "", error: "The session is not paused." };
386
+ const frame = this.paused!.callFrames[frameIndex];
387
+ if (!frame) return { success: false, output: "", error: `No stack frame at index ${frameIndex}.` };
388
+ const scopes = frame.scopeChain.map((s) => ({ type: s.type, objectId: s.object.objectId }));
389
+ return { success: true, output: scopes.map((s) => s.objectId ? `${s.type}: ${s.objectId}` : `${s.type} (no variables)`).join("\n"), scopes };
390
+ }
391
+
392
+ async variables(objectId: string): Promise<{ success: boolean; output: string; error?: string }> {
393
+ if (!this.isActive) return { success: false, output: "", error: "No active debug session." };
394
+ try {
395
+ const res = await this.send("Runtime.getProperties", { objectId, ownProperties: true });
396
+ const lines = (res.result ?? [])
397
+ .filter((p: any) => p.enumerable !== false)
398
+ .map((p: any) => `${p.name} = ${this.describeRemoteObject(p.value)}`);
399
+ return { success: true, output: lines.length ? lines.join("\n") : "(no properties)" };
400
+ } catch (err: any) {
401
+ return { success: false, output: "", error: err.message };
402
+ }
403
+ }
404
+
405
+ threads(): { success: boolean; output: string } {
406
+ return { success: true, output: this.isActive ? "1 thread: main (node is single-threaded here)" : "No active debug session." };
407
+ }
408
+
409
+ async terminate(): Promise<{ success: boolean; output: string }> {
410
+ const wasActive = this.isActive;
411
+ try {
412
+ this.ws?.close();
413
+ } catch { /* best-effort */ }
414
+ this.ws = undefined;
415
+ if (this.proc) {
416
+ try {
417
+ this.proc.kill();
418
+ } catch { /* best-effort */ }
419
+ }
420
+ this.proc = undefined;
421
+ this.paused = undefined;
422
+ this.pending.clear();
423
+ const waiters = this.pausedWaiters.splice(0);
424
+ for (const w of waiters) w({ exited: true, code: this.exitCode ?? null });
425
+ return { success: true, output: wasActive ? "Debug session terminated." : "No active debug session." };
426
+ }
427
+ }
428
+
429
+ /** Process-wide singleton — one active debug session (gjc `debug` tool parity). */
430
+ export const debugSession = new DebugSession();
431
+
432
+ // Safety net: never leave a debugged `node` process running past the CLI's own exit.
433
+ process.on("exit", () => {
434
+ void debugSession.terminate();
435
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `debug` tool — Node.js debugging via the in-process CDP client in
3
+ * `debug-session.ts` (gjc `debug` parity, Node.js JS/TS ONLY — no Bun target, no
4
+ * DAP wire protocol, no other languages; see that file's header for the full scope
5
+ * statement). MUTATING: runs arbitrary user code as a real OS process, so it is
6
+ * excluded from read-only subagent roles the same way `bash`/`computer` are.
7
+ */
8
+ import type { ToolHandler } from "./engine";
9
+ import type { ToolResult } from "./tools";
10
+ import { debugSession } from "./debug-session";
11
+
12
+ /** One-line protocol description appended to the launch system prompt. */
13
+ export const DEBUG_TOOL_PROTOCOL_LINE =
14
+ `debug {action, ...} — Node.js debugging (one active session; 'launch' replaces any previous one). ` +
15
+ `launch {program, args?, cwd?} spawns 'node --inspect-brk' and pauses at program start; set_breakpoint ` +
16
+ `{file, line, condition?} / remove_breakpoint {id}; continue / step_over / step_in / step_out / pause resume ` +
17
+ `or step and report the next pause (or program exit); evaluate {expression, frame?}; stack_trace; scopes ` +
18
+ `{frame?} (lists 'type: objectId'); variables {object_id}; threads; output (captured stdout/stderr so far); ` +
19
+ `terminate.`;
20
+
21
+ function err(message: string): ToolResult {
22
+ return { success: false, output: "", error: message };
23
+ }
24
+
25
+ export function createDebugTool(): ToolHandler {
26
+ return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
27
+ const action = String(args.action ?? "").trim().toLowerCase();
28
+
29
+ if (action === "launch") {
30
+ const program = typeof args.program === "string" ? args.program.trim() : "";
31
+ if (!program) return err(`debug {action:"launch"} requires a non-empty "program".`);
32
+ const scriptArgs = Array.isArray(args.args) ? args.args.map((a: unknown) => String(a)) : [];
33
+ const resolvedCwd = typeof args.cwd === "string" && args.cwd.trim() ? args.cwd : cwd;
34
+ return debugSession.launch(program, scriptArgs, resolvedCwd);
35
+ }
36
+
37
+ if (action === "set_breakpoint") {
38
+ const file = typeof args.file === "string" ? args.file.trim() : "";
39
+ if (!file) return err(`debug {action:"set_breakpoint"} requires a non-empty "file".`);
40
+ const line = typeof args.line === "number" ? args.line : parseInt(String(args.line ?? ""), 10);
41
+ if (!Number.isFinite(line) || line < 1) return err(`debug {action:"set_breakpoint"} requires a positive integer "line".`);
42
+ const condition = typeof args.condition === "string" && args.condition.trim() ? args.condition : undefined;
43
+ return debugSession.setBreakpoint(file, line, cwd, condition);
44
+ }
45
+
46
+ if (action === "remove_breakpoint") {
47
+ const id = typeof args.id === "string" ? args.id.trim() : "";
48
+ if (!id) return err(`debug {action:"remove_breakpoint"} requires a non-empty "id".`);
49
+ return debugSession.removeBreakpoint(id);
50
+ }
51
+
52
+ if (action === "continue") return debugSession.continue();
53
+ if (action === "step_over") return debugSession.stepOver();
54
+ if (action === "step_in") return debugSession.stepIn();
55
+ if (action === "step_out") return debugSession.stepOut();
56
+ if (action === "pause") return debugSession.pause();
57
+
58
+ if (action === "evaluate") {
59
+ const expression = typeof args.expression === "string" ? args.expression : "";
60
+ if (!expression.trim()) return err(`debug {action:"evaluate"} requires a non-empty "expression".`);
61
+ const frame = typeof args.frame === "number" ? args.frame : 0;
62
+ return debugSession.evaluate(expression, frame);
63
+ }
64
+
65
+ if (action === "stack_trace") return debugSession.stackTrace();
66
+
67
+ if (action === "scopes") {
68
+ const frame = typeof args.frame === "number" ? args.frame : 0;
69
+ const res = debugSession.scopes(frame);
70
+ return { success: res.success, output: res.output, error: res.error };
71
+ }
72
+
73
+ if (action === "variables") {
74
+ const objectId = typeof args.object_id === "string" ? args.object_id
75
+ : typeof args.objectId === "string" ? args.objectId : "";
76
+ if (!objectId) return err(`debug {action:"variables"} requires a non-empty "object_id" (from a prior 'scopes' call).`);
77
+ return debugSession.variables(objectId);
78
+ }
79
+
80
+ if (action === "threads") return debugSession.threads();
81
+ if (action === "output") return { success: true, output: debugSession.output() || "(no output yet)" };
82
+ if (action === "terminate") return debugSession.terminate();
83
+
84
+ return err(
85
+ `Unknown debug action '${action}'. Use launch | set_breakpoint | remove_breakpoint | continue | ` +
86
+ `step_over | step_in | step_out | pause | evaluate | stack_trace | scopes | variables | threads | output | terminate.`,
87
+ );
88
+ };
89
+ }
@@ -15,6 +15,13 @@ import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
15
15
  import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
16
16
  import { webSearchTool, setWebSearchActiveModel } from "./web-search";
17
17
  import { executeComputerAction } from "../commands/computer";
18
+ import { computerSupervisor } from "./computer-supervisor";
19
+ import { createAstGrepTool } from "./ast-grep-tool";
20
+ import { createAstEditTool } from "./ast-edit-tool";
21
+ import { createLspTool } from "./lsp-tool";
22
+ import { createLspRenameTool } from "./lsp-rename-tool";
23
+ import { createDebugTool } from "./debug-tool";
24
+ import { createBrowserTool } from "./browser-tool";
18
25
  import { isRateLimitError, waitAbortable } from "../util/retry";
19
26
  import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
20
27
  import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
@@ -82,7 +89,22 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
82
89
  mkdir: (a, cwd) => mkdirTool(a.dirPath ?? a.path ?? a.dir, cwd),
83
90
  delete: (a, cwd) => deleteTool(a.path ?? a.filePath ?? a.targetPath ?? a.dirPath, cwd, !!(a.recursive ?? a.r)),
84
91
  web_search: (a, cwd) => webSearchTool(a, cwd),
85
- computer: (a) => executeComputerAction(a as any),
92
+ // Interactive-loop parity with the one-shot `jeo computer` CLI (see
93
+ // src/commands/computer.ts's runComputerCommand): arm the fail-closed
94
+ // supervisor and refresh its heartbeat immediately before EVERY call, so the
95
+ // kill-switch/heartbeat gate inside executeComputerAction passes for an
96
+ // actively-invoked tool call — it is never "always on" between calls.
97
+ computer: (a) => {
98
+ computerSupervisor.setKillSwitchLive(true);
99
+ computerSupervisor.heartbeat();
100
+ return executeComputerAction(a as any);
101
+ },
102
+ ast_grep: createAstGrepTool(),
103
+ ast_edit: createAstEditTool(),
104
+ lsp: createLspTool(),
105
+ lsp_rename: createLspRenameTool(),
106
+ debug: createDebugTool(),
107
+ browser: createBrowserTool(),
86
108
  };
87
109
  /** Tool-protocol description injected into the system prompt. */
88
110
  export const TOOL_PROTOCOL = [
@@ -98,7 +120,13 @@ export const TOOL_PROTOCOL = [
98
120
  "9. delete {path, recursive?} — remove a file (or directory with recursive:true)",
99
121
  "10. web_search {query, recency?, limit?} — search the web (Anthropic-native: synthesized answer + sources + citations)",
100
122
  "11. computer {action, x?, y?, text?, key?, deltaX?, deltaY?, duration?, actions?} — execute desktop automation actions (screenshot, click, double_click, move, drag, scroll, type, keypress, wait, batch)",
101
- "12. done {reason?} — call when the task is fully implemented AND verified",
123
+ "12. ast_grep {pattern, paths} — structural TypeScript/JavaScript search: $NAME captures one node (repeats must match identical code), $_ is a wildcard node, $$$NAME/$$$ capture/ignore zero-or-more sibling nodes in a list (call args, statements, class members, ...)",
124
+ "13. ast_edit {pattern, replacement, paths} — structural TypeScript/JavaScript rewrite; same pattern metavariables, 'replacement' substitutes $NAME/$$$NAME with captured text (empty replacement deletes matches)",
125
+ "14. lsp {action, file, line?, symbol?} — TypeScript/JavaScript language-service queries (definition/references/hover/symbols/diagnostics); 'symbol' resolves the column on 'line' (append #N for the Nth occurrence)",
126
+ "15. lsp_rename {file, line, symbol?, new_name, apply?} — cross-file TypeScript/JavaScript rename (apply defaults true; apply:false previews only)",
127
+ "16. debug {action, ...} — Node.js debugging (launch/set_breakpoint/continue/step_over/step_in/step_out/pause/evaluate/stack_trace/scopes/variables/threads/output/terminate); one active session, 'launch' spawns 'node --inspect-brk' and pauses at start",
128
+ "17. browser {action:\"open\"|\"close\"|\"run\"|\"act\", name?, url?, actions?} — headless Chromium automation (Playwright); 'act' runs structured steps (navigate/click/type/fill/select/press/scroll/back/wait/observe/extract/screenshot) addressing elements by numeric id from 'observe' or a CSS selector; 'run' executes JS with page/browser/tab/display/assert/wait in scope",
129
+ "18. done {reason?} — call when the task is fully implemented AND verified",
102
130
  "",
103
131
  "Reply with STRICT JSON only — no code fences. You MAY include an optional leading",
104
132
  '"reasoning" string (one short sentence on your plan) before "tool":',
@@ -120,9 +148,11 @@ export const READONLY_TOOL_PROTOCOL = [
120
148
  "1. read {filePath, lineRange?} — read a file (lineRange: \"a-b\", \"a-\", \"a\", \"a+n\", or multi \"a-b,c-d\")",
121
149
  "2. find {globPattern} — find files by name",
122
150
  "3. search {pattern, globPattern?, ignoreCase?} — grep for a pattern",
123
- "4. ls {dirPath} — list a directory's entries",
124
- "5. web_search {query, recency?, limit?} — search the web (answer + sources + citations)",
125
- "6. done {reason?} — call when your review/analysis is complete",
151
+ "4. ast_grep {pattern, paths} — structural TypeScript/JavaScript search ($NAME/$_/$$$NAME metavariables — see ast_grep for full syntax)",
152
+ "5. lsp {action, file, line?, symbol?} — TypeScript/JavaScript definition/references/hover/symbols/diagnostics (read-only; cross-file rename is not available to this role)",
153
+ "6. ls {dirPath} — list a directory's entries",
154
+ "7. web_search {query, recency?, limit?} — search the web (answer + sources + citations)",
155
+ "8. done {reason?} — call when your review/analysis is complete",
126
156
  "",
127
157
  "Reply with STRICT JSON only — no prose, no code fences:",
128
158
  '{ "tool": "<name>", "arguments": { ... } }',
@@ -441,6 +471,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
441
471
  let lastMutationSeq = 0;
442
472
  let lastVerificationSeq = 0;
443
473
  let donePushbackUsed = false;
474
+ // Abuse guard for the done-pushback escape hatch: the "second done always
475
+ // passes" latch below is meant for turns where verification is genuinely
476
+ // not applicable (docs/config-only changes) — not for a model that just
477
+ // resends `done` immediately with zero intervening action. Reset to false
478
+ // when the first pushback fires; any executed tool step (even read-only)
479
+ // sets it back to true, so the escape hatch requires at least one attempt.
480
+ let sawActionSincePushback = true;
444
481
  // Caller-owned done gate (onBeforeDone) — also strictly once per turn.
445
482
  let beforeDoneNudgeUsed = false;
446
483
  // F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
@@ -806,12 +843,32 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
806
843
  verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
807
844
  pendingHookFailure,
808
845
  });
809
- if (doneGate.block && !donePushbackUsed) {
810
- donePushbackUsed = true; // second done always passes — escape hatch
811
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
812
- history.push({ role: "user", content: doneGate.message });
813
- step++;
814
- continue;
846
+ if (doneGate.block) {
847
+ if (!donePushbackUsed) {
848
+ donePushbackUsed = true;
849
+ sawActionSincePushback = false; // require a real attempt before the 2nd done is honored
850
+ pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
851
+ history.push({ role: "user", content: doneGate.message });
852
+ step++;
853
+ continue;
854
+ }
855
+ if (!sawActionSincePushback) {
856
+ // Abuse guard: the model re-sent `done` immediately with zero intervening
857
+ // tool calls — not the intended "verification genuinely not applicable"
858
+ // escape hatch. Bounce once more instead of silently accepting it.
859
+ pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
860
+ history.push({
861
+ role: "user",
862
+ content:
863
+ `${doneGate.message}\n\nYou called 'done' again without taking any further action. ` +
864
+ `Either run the relevant verification (test/build/lint) or take another concrete step, ` +
865
+ `then call done — do not resend 'done' unchanged.`,
866
+ });
867
+ step++;
868
+ continue;
869
+ }
870
+ // donePushbackUsed && sawActionSincePushback: the model made at least one
871
+ // real attempt since the pushback — honor the escape hatch.
815
872
  }
816
873
  // Caller-owned done gate (e.g. stale-todo reconciliation): ONE bounded
817
874
  // bounce, then any later done passes — field case: a 28-step turn ended
@@ -1173,7 +1230,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1173
1230
  // here, so this always holds the last REAL execution's results.
1174
1231
  lastResults = results;
1175
1232
  // Executed tool step = progress: the stall budget clocks time WITHOUT this.
1176
- if (results.some(r => r.executed)) lastProgressAt = Date.now();
1233
+ if (results.some(r => r.executed)) {
1234
+ lastProgressAt = Date.now();
1235
+ sawActionSincePushback = true; // any real step after a pushback re-enables the done escape hatch
1236
+ }
1177
1237
  step++;
1178
1238
  }
1179
1239