jeo-code 0.7.31 → 0.7.32

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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Structural AST pattern matcher for TypeScript/JavaScript (gjc `ast_grep`/`ast_edit`
3
+ * parity, scoped to TS/JS/TSX/JSX — jeo-code has zero native dependencies, so this is
4
+ * a pure-TS reimplementation of ast-grep's metavariable matching against the
5
+ * `typescript` compiler API's AST, NOT the real ast-grep binary and NOT multi-language.
6
+ *
7
+ * Pattern syntax (matches the `ast_grep`/`ast_edit` tool contract):
8
+ * - `$NAME` — capture exactly ONE node; two occurrences of the same name in a
9
+ * pattern must match IDENTICAL source text (e.g. `assertEqual($A, $A)`).
10
+ * - `$_` — wildcard, matches exactly one node without binding a capture.
11
+ * - `$$$NAME`/`$$$` — capture/ignore ZERO OR MORE sibling nodes in a LIST position
12
+ * (call arguments, array/object elements, statement lists, class members,
13
+ * named-import specifiers, parameter lists, ...). At most one `$$$`-style
14
+ * metavariable per list is supported.
15
+ *
16
+ * Implementation notes:
17
+ * - `ts.Node.forEachChild(cbNode, cbNodeArray)` is used to enumerate a node's
18
+ * children in canonical source order, distinguishing single-child "slots" from
19
+ * list ("NodeArray") slots — this is what lets `$$$` bind a variable-length
20
+ * span instead of a single node.
21
+ * - A metavariable identifier is frequently parsed WRAPPED in a structural node
22
+ * rather than standing bare — e.g. `($$$ARGS)` is a lone `ParameterDeclaration`
23
+ * whose `.name` is the identifier, `{ $$$IMPORTS }` is a lone `ImportSpecifier`,
24
+ * `: $_` is a `TypeReferenceNode`, `{ $$$BODY }` is a `Block` containing one
25
+ * `ExpressionStatement`. `unwrapBareWrapper()` sees through these SPECIFIC,
26
+ * well-known "no extra shape" wrappers so metavariable detection is not
27
+ * accidentally defeated by the parser's own AST shape.
28
+ * - Structural comparison otherwise requires the SAME `SyntaxKind` and recurses
29
+ * slot-by-slot. A pattern is allowed to OMIT trailing optional slots the
30
+ * candidate has (e.g. no `else` branch, no return type) and to be missing a
31
+ * LEADING/MIDDLE list-shaped slot the candidate has (typically `modifiers`/
32
+ * `decorators`, e.g. matching un-exported AND exported declarations with the
33
+ * same pattern) — up to a small bounded number of skipped candidate slots.
34
+ */
35
+ import * as ts from "typescript";
36
+
37
+ export type MetaKind = "single" | "multi" | "wild" | "wildmulti";
38
+ export interface Meta { kind: MetaKind; name: string }
39
+
40
+ function unwrapBareWrapper(node: ts.Node): ts.Node | null {
41
+ if (ts.isParameter(node) && ts.isIdentifier(node.name) && !node.type && !node.initializer
42
+ && !node.questionToken && !node.dotDotDotToken && (!node.modifiers || node.modifiers.length === 0)) {
43
+ return node.name;
44
+ }
45
+ if (ts.isImportSpecifier(node) && !node.propertyName && !node.isTypeOnly) return node.name;
46
+ if (ts.isExportSpecifier(node) && !node.propertyName && !node.isTypeOnly) return node.name;
47
+ if (ts.isBindingElement(node) && !node.propertyName && !node.initializer && !node.dotDotDotToken && ts.isIdentifier(node.name)) return node.name;
48
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && !node.typeArguments) return node.typeName;
49
+ if (ts.isExpressionStatement(node)) return node.expression;
50
+ if (ts.isShorthandPropertyAssignment(node) && !node.objectAssignmentInitializer) return node.name;
51
+ return null;
52
+ }
53
+
54
+ /** Classify a (possibly wrapper-unwrapped) node as a metavariable, or `null` for
55
+ * an ordinary AST node that must be matched structurally. */
56
+ export function metaOf(node: ts.Node): Meta | null {
57
+ const inner = unwrapBareWrapper(node);
58
+ if (inner) return metaOf(inner);
59
+ if (!ts.isIdentifier(node)) return null;
60
+ const t = node.text;
61
+ if (t === "$_") return { kind: "wild", name: "_" };
62
+ if (t === "$$$") return { kind: "wildmulti", name: "_" };
63
+ const m3 = /^\$\$\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(t);
64
+ if (m3) return { kind: "multi", name: m3[1]! };
65
+ const m1 = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(t);
66
+ if (m1) return { kind: "single", name: m1[1]! };
67
+ return null;
68
+ }
69
+
70
+ export interface Bindings {
71
+ single: Map<string, string>;
72
+ multi: Map<string, string[]>;
73
+ }
74
+
75
+ function newBindings(): Bindings {
76
+ return { single: new Map(), multi: new Map() };
77
+ }
78
+
79
+ type Slot = { kind: "node"; node: ts.Node } | { kind: "list"; nodes: readonly ts.Node[] };
80
+
81
+ function slotsOf(node: ts.Node): Slot[] {
82
+ const out: Slot[] = [];
83
+ node.forEachChild(
84
+ child => { out.push({ kind: "node", node: child }); return undefined; },
85
+ arr => { out.push({ kind: "list", nodes: arr }); return undefined; },
86
+ );
87
+ return out;
88
+ }
89
+
90
+ function leafText(node: ts.Node): string | undefined {
91
+ if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) return node.text;
92
+ if (ts.isStringLiteralLike(node)) return node.text;
93
+ if (ts.isNumericLiteral(node) || ts.isBigIntLiteral(node)) return node.text;
94
+ return undefined;
95
+ }
96
+
97
+ /** How many candidate-only slots (e.g. `export`/decorators the pattern omitted) a
98
+ * single structural comparison may skip over before giving up. */
99
+ const MAX_SLOT_SKIPS = 2;
100
+
101
+ function matchNode(pattern: ts.Node, patternSf: ts.SourceFile, candidate: ts.Node, candidateSf: ts.SourceFile, b: Bindings): boolean {
102
+ const meta = metaOf(pattern);
103
+ if (meta) {
104
+ if (meta.kind === "wild") return true;
105
+ if (meta.kind === "single") {
106
+ const text = candidate.getText(candidateSf).trim();
107
+ const prev = b.single.get(meta.name);
108
+ if (prev !== undefined) return prev === text;
109
+ b.single.set(meta.name, text);
110
+ return true;
111
+ }
112
+ // `$$$NAME`/`$$$` reached outside a list slot (unusual pattern shape) — fall back
113
+ // to treating it as a single capture of the whole candidate rather than failing.
114
+ const text = candidate.getText(candidateSf).trim();
115
+ if (meta.kind === "multi") {
116
+ const prev = b.multi.get(meta.name);
117
+ if (prev !== undefined) return prev.join(" ") === text;
118
+ b.multi.set(meta.name, [text]);
119
+ }
120
+ return true;
121
+ }
122
+ if (pattern.kind !== candidate.kind) return false;
123
+ const lt = leafText(pattern);
124
+ if (lt !== undefined) return lt === leafText(candidate);
125
+
126
+ const pSlots = slotsOf(pattern);
127
+ const cSlots = slotsOf(candidate);
128
+ let pi = 0, ci = 0, skips = 0;
129
+ while (pi < pSlots.length) {
130
+ const ps = pSlots[pi]!;
131
+ if (ci >= cSlots.length) return false;
132
+ const cs = cSlots[ci]!;
133
+ if (ps.kind === cs.kind) {
134
+ const ok = ps.kind === "list"
135
+ ? matchList(ps.nodes, patternSf, (cs as { kind: "list"; nodes: readonly ts.Node[] }).nodes, candidateSf, b)
136
+ : matchNode((ps as { kind: "node"; node: ts.Node }).node, patternSf, (cs as { kind: "node"; node: ts.Node }).node, candidateSf, b);
137
+ if (!ok) {
138
+ if (skips < MAX_SLOT_SKIPS && ci + 1 < cSlots.length) { skips++; ci++; continue; }
139
+ return false;
140
+ }
141
+ pi++; ci++;
142
+ continue;
143
+ }
144
+ // Slot-KIND mismatch (list vs single) at this relative position — most commonly a
145
+ // `modifiers`/`decorators` list the candidate has and the pattern omitted. Skip the
146
+ // candidate slot and retry the SAME pattern slot against the next one.
147
+ if (skips < MAX_SLOT_SKIPS) { skips++; ci++; continue; }
148
+ return false;
149
+ }
150
+ return true; // trailing extra candidate slots are fine — unspecified optional parts
151
+ }
152
+
153
+ function matchList(patternNodes: readonly ts.Node[], patternSf: ts.SourceFile, candidateNodes: readonly ts.Node[], candidateSf: ts.SourceFile, b: Bindings): boolean {
154
+ const idx = patternNodes.findIndex(n => { const m = metaOf(n); return m?.kind === "multi" || m?.kind === "wildmulti"; });
155
+ if (idx === -1) {
156
+ if (patternNodes.length !== candidateNodes.length) return false;
157
+ for (let i = 0; i < patternNodes.length; i++) {
158
+ if (!matchNode(patternNodes[i]!, patternSf, candidateNodes[i]!, candidateSf, b)) return false;
159
+ }
160
+ return true;
161
+ }
162
+ const prefixLen = idx;
163
+ const suffixLen = patternNodes.length - idx - 1;
164
+ if (candidateNodes.length < prefixLen + suffixLen) return false;
165
+ for (let i = 0; i < prefixLen; i++) {
166
+ if (!matchNode(patternNodes[i]!, patternSf, candidateNodes[i]!, candidateSf, b)) return false;
167
+ }
168
+ for (let i = 0; i < suffixLen; i++) {
169
+ if (!matchNode(patternNodes[patternNodes.length - 1 - i]!, patternSf, candidateNodes[candidateNodes.length - 1 - i]!, candidateSf, b)) return false;
170
+ }
171
+ const middle = candidateNodes.slice(prefixLen, candidateNodes.length - suffixLen);
172
+ const meta = metaOf(patternNodes[idx]!)!;
173
+ if (meta.kind === "multi") {
174
+ const texts = middle.map(n => n.getText(candidateSf).trim());
175
+ const prev = b.multi.get(meta.name);
176
+ if (prev !== undefined) return prev.join(",") === texts.join(",");
177
+ b.multi.set(meta.name, texts);
178
+ }
179
+ return true;
180
+ }
181
+
182
+ /** Parse a pattern string to a single matchable node. Multi-statement snippets are
183
+ * rejected with a clear error — wrap them in a block/valid context instead (same
184
+ * discipline as real ast-grep). A bare expression statement is unwrapped to its
185
+ * expression so `$A && $A()` matches an Expression, not only an ExpressionStatement. */
186
+ export function parsePattern(src: string): { node: ts.Node; sf: ts.SourceFile } {
187
+ const sf = ts.createSourceFile("__pattern__.tsx", src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
188
+ if (sf.statements.length !== 1) {
189
+ throw new Error(`pattern must parse to exactly one top-level statement (got ${sf.statements.length}) — wrap multi-statement snippets in a block or valid context`);
190
+ }
191
+ let node: ts.Node = sf.statements[0]!;
192
+ if (ts.isExpressionStatement(node)) node = node.expression;
193
+ return { node, sf };
194
+ }
195
+
196
+ export interface MatchCaptures {
197
+ single: Record<string, string>;
198
+ multi: Record<string, string[]>;
199
+ }
200
+
201
+ export interface MatchResult {
202
+ node: ts.Node;
203
+ captures: MatchCaptures;
204
+ }
205
+
206
+ /** Find every non-overlapping-by-construction match of `patternSrc` in `sourceFile`.
207
+ * A fast prefilter only attempts nodes whose `SyntaxKind` equals the pattern's root
208
+ * kind (unless the pattern root is itself a bare metavariable). */
209
+ export function findMatches(patternSrc: string, sourceFile: ts.SourceFile): MatchResult[] {
210
+ const { node: patternNode, sf: patternSf } = parsePattern(patternSrc);
211
+ const patternIsBareMeta = metaOf(patternNode) !== null;
212
+ const out: MatchResult[] = [];
213
+ const visit = (node: ts.Node) => {
214
+ if (patternIsBareMeta || node.kind === patternNode.kind) {
215
+ const b = newBindings();
216
+ if (matchNode(patternNode, patternSf, node, sourceFile, b)) {
217
+ out.push({ node, captures: { single: Object.fromEntries(b.single), multi: Object.fromEntries(b.multi) } });
218
+ }
219
+ }
220
+ node.forEachChild(visit);
221
+ };
222
+ sourceFile.forEachChild(visit);
223
+ return out;
224
+ }
225
+
226
+ /** Render a replacement template, substituting `$NAME`/`$$$NAME` with their bound
227
+ * capture text (multi-captures join with ", "). An unbound metavariable (including
228
+ * `$_`/`$$$`, which never bind) is left as literal text in the output. */
229
+ export function renderReplacement(template: string, captures: MatchCaptures): string {
230
+ return template.replace(/\$\$\$([A-Za-z_][A-Za-z0-9_]*)|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, multiName?: string, singleName?: string) => {
231
+ if (multiName !== undefined) {
232
+ const vals = captures.multi[multiName];
233
+ return vals !== undefined ? vals.join(", ") : whole;
234
+ }
235
+ if (singleName !== undefined) {
236
+ const val = captures.single[singleName];
237
+ return val !== undefined ? val : whole;
238
+ }
239
+ return whole;
240
+ });
241
+ }
@@ -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
+ });