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.
- package/CHANGELOG.md +33 -0
- package/README.ja.md +4 -2
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/README.zh.md +4 -2
- package/package.json +4 -4
- package/scripts/install.sh +19 -0
- package/scripts/uninstall.sh +2 -2
- package/src/agent/AGENTS.md +15 -0
- package/src/agent/ast-edit-tool.ts +123 -0
- package/src/agent/ast-file-scan.ts +62 -0
- package/src/agent/ast-grep-tool.ts +107 -0
- package/src/agent/ast-match.ts +241 -0
- package/src/agent/browser-session.ts +104 -0
- package/src/agent/browser-tab.ts +145 -0
- package/src/agent/browser-tool.ts +188 -0
- package/src/agent/debug-session.ts +435 -0
- package/src/agent/debug-tool.ts +89 -0
- package/src/agent/engine.ts +72 -12
- package/src/agent/goal-tool.ts +57 -0
- package/src/agent/irc-tool.ts +71 -0
- package/src/agent/job-registry.ts +163 -0
- package/src/agent/job-tool.ts +106 -0
- package/src/agent/lsp-rename-tool.ts +108 -0
- package/src/agent/lsp-tool.ts +177 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +40 -4
- package/src/agent/subagent-tool.ts +27 -7
- package/src/agent/subagents.ts +1 -1
- package/src/agent/task-tool.ts +27 -5
- package/src/agent/tools.ts +68 -1
- package/src/agent/ts-language-service.ts +173 -0
- package/src/commands/launch/agents-slash.ts +489 -0
- package/src/commands/launch/flags.ts +27 -9
- package/src/commands/launch/model-slash.ts +290 -0
- package/src/commands/launch/session-slash.ts +305 -0
- package/src/commands/launch/slash-handlers.ts +6 -0
- package/src/commands/launch/system-slash.ts +81 -0
- 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
|
+
}
|
package/src/agent/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
124
|
-
"5.
|
|
125
|
-
"6.
|
|
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
|
|
810
|
-
donePushbackUsed
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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))
|
|
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
|
|