github-router 0.3.72 → 0.3.74
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/dist/browser-ext/background.js +1 -1
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/lib/tree-sitter-pool/worker.js +543 -0
- package/dist/lifecycle-BL4rWSrT.js +4 -0
- package/dist/{lifecycle-CSzT74Yn.js → lifecycle-CMPthagV.js} +2 -2
- package/dist/{lifecycle-CSzT74Yn.js.map → lifecycle-CMPthagV.js.map} +1 -1
- package/dist/{lifecycle-YtwlmQU7.js → lifecycle-CQlm3YlF.js} +2 -2
- package/dist/lifecycle-yaqqtsV1.js +452 -0
- package/dist/lifecycle-yaqqtsV1.js.map +1 -0
- package/dist/main.js +4498 -1702
- package/dist/main.js.map +1 -1
- package/dist/{paths-Dv7QZQWB.js → paths-BGx0RpNs.js} +1 -1
- package/dist/{paths-CutqqG7k.js → paths-yJ97KlKp.js} +25 -2
- package/dist/paths-yJ97KlKp.js.map +1 -0
- package/package.json +1 -1
- package/dist/paths-CutqqG7k.js.map +0 -1
|
@@ -859,7 +859,7 @@ function clampNum(v, min, max) {
|
|
|
859
859
|
// attachment, so two parallel browser_mouse / browser_drag / browser_type
|
|
860
860
|
// calls on the same tab would interleave and corrupt each other (one
|
|
861
861
|
// call's mouseMoved would land mid-drag of another). The global
|
|
862
|
-
// MAX_INFLIGHT_TOOLS_CALL=
|
|
862
|
+
// MAX_INFLIGHT_TOOLS_CALL=32 cap doesn't help — it's global, not per-tab.
|
|
863
863
|
// This mutex is per-tab, layered on top.
|
|
864
864
|
const tabInputLockTails = new Map() // tabId → Promise (tail of the lock chain)
|
|
865
865
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "github-router browser bridge",
|
|
4
4
|
"short_name": "gh-router-browser",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.74",
|
|
6
6
|
"description": "Bridge between Claude (via github-router /mcp) and the browser. Implements tab control, navigation, clicks, form fill, downloads, screenshots, devtools eval. Blocks navigation to chrome://settings.",
|
|
7
7
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJElxuBlonBS3TVW9FJN0mGTtShB3L1hoaYf6k39SOr1ogGYmF90EjRxy1i21k9wQQjPf26bcBu/9X67KrQjQV0uB38CaNukgiSeoLjfptN811u+PJHx6BP+jx3Qa6/3VenNPxHC8WEU0GXql8QSjIHEyCwKb6fMASXOK94JyB5Ywov2x8mt/+9ncqBBBMVzf6r5Sagy4PL1XnryLsuADD/vOEkPet8wXgH/Oj7v5tTsQQZ7U1JT51PoDs2BFnXc5v3TkVgZwd32k3ONh+nkDw1Hof+4zwUGOyJE6eMrlYzRlKM4Qxdf9JpavQvqfieAbTRWcyKeclnHeoIfE7cDBQIDAQAB",
|
|
8
8
|
"background": {
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { parentPort } from "node:worker_threads";
|
|
4
|
+
import Parser from "web-tree-sitter";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import consola from "consola";
|
|
7
|
+
|
|
8
|
+
//#region rolldown:runtime
|
|
9
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/lib/tree-sitter-grammars.ts
|
|
13
|
+
/**
|
|
14
|
+
* Cap the per-file size we'll parse. 1MB of source covers all
|
|
15
|
+
* reasonable hand-written files; bigger files are almost always
|
|
16
|
+
* generated code or vendored bundles whose AST signal is worthless
|
|
17
|
+
* for ranking real definitions.
|
|
18
|
+
*/
|
|
19
|
+
const STRUCTURAL_MAX_FILE_BYTES = 1024 * 1024;
|
|
20
|
+
/**
|
|
21
|
+
* Grammar key → wasm filename under `node_modules/tree-sitter-wasms/out/`.
|
|
22
|
+
* Resolved at runtime from `node_modules`; the file paths are stable
|
|
23
|
+
* because `tree-sitter-wasms` ships prebuilt binaries (no per-install
|
|
24
|
+
* codegen).
|
|
25
|
+
*/
|
|
26
|
+
const GRAMMAR_FILES = {
|
|
27
|
+
typescript: "tree-sitter-typescript.wasm",
|
|
28
|
+
tsx: "tree-sitter-tsx.wasm",
|
|
29
|
+
javascript: "tree-sitter-javascript.wasm",
|
|
30
|
+
python: "tree-sitter-python.wasm",
|
|
31
|
+
go: "tree-sitter-go.wasm",
|
|
32
|
+
rust: "tree-sitter-rust.wasm",
|
|
33
|
+
java: "tree-sitter-java.wasm",
|
|
34
|
+
c: "tree-sitter-c.wasm",
|
|
35
|
+
cpp: "tree-sitter-cpp.wasm"
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Per-language definition-shape node types. When a matched identifier
|
|
39
|
+
* sits inside one of these nodes AND is at the node's "name" position,
|
|
40
|
+
* we have AST-confirmed evidence the line is an identifier-definition
|
|
41
|
+
* site. The brief's enumeration plus a handful of language-idiomatic
|
|
42
|
+
* extras (e.g., `lexical_declaration` for TS/JS top-level `const`s,
|
|
43
|
+
* `mod_item` for Rust modules).
|
|
44
|
+
*
|
|
45
|
+
* The set lookup is per-language so a node type that means
|
|
46
|
+
* "definition" in one language but "reference" in another won't
|
|
47
|
+
* cross-pollute.
|
|
48
|
+
*/
|
|
49
|
+
const DEFINITION_NODE_TYPES = {
|
|
50
|
+
typescript: new Set([
|
|
51
|
+
"function_declaration",
|
|
52
|
+
"function_signature",
|
|
53
|
+
"function_expression",
|
|
54
|
+
"method_definition",
|
|
55
|
+
"method_signature",
|
|
56
|
+
"class_declaration",
|
|
57
|
+
"interface_declaration",
|
|
58
|
+
"type_alias_declaration",
|
|
59
|
+
"enum_declaration",
|
|
60
|
+
"variable_declarator",
|
|
61
|
+
"generator_function_declaration",
|
|
62
|
+
"abstract_method_signature",
|
|
63
|
+
"public_field_definition",
|
|
64
|
+
"property_signature"
|
|
65
|
+
]),
|
|
66
|
+
tsx: new Set([
|
|
67
|
+
"function_declaration",
|
|
68
|
+
"function_signature",
|
|
69
|
+
"function_expression",
|
|
70
|
+
"method_definition",
|
|
71
|
+
"method_signature",
|
|
72
|
+
"class_declaration",
|
|
73
|
+
"interface_declaration",
|
|
74
|
+
"type_alias_declaration",
|
|
75
|
+
"enum_declaration",
|
|
76
|
+
"variable_declarator",
|
|
77
|
+
"generator_function_declaration",
|
|
78
|
+
"abstract_method_signature",
|
|
79
|
+
"public_field_definition",
|
|
80
|
+
"property_signature"
|
|
81
|
+
]),
|
|
82
|
+
javascript: new Set([
|
|
83
|
+
"function_declaration",
|
|
84
|
+
"function_expression",
|
|
85
|
+
"method_definition",
|
|
86
|
+
"class_declaration",
|
|
87
|
+
"variable_declarator",
|
|
88
|
+
"generator_function_declaration"
|
|
89
|
+
]),
|
|
90
|
+
python: new Set([
|
|
91
|
+
"function_definition",
|
|
92
|
+
"class_definition",
|
|
93
|
+
"decorated_definition"
|
|
94
|
+
]),
|
|
95
|
+
go: new Set([
|
|
96
|
+
"function_declaration",
|
|
97
|
+
"method_declaration",
|
|
98
|
+
"type_spec",
|
|
99
|
+
"type_alias",
|
|
100
|
+
"const_spec",
|
|
101
|
+
"var_spec"
|
|
102
|
+
]),
|
|
103
|
+
rust: new Set([
|
|
104
|
+
"function_item",
|
|
105
|
+
"impl_item",
|
|
106
|
+
"trait_item",
|
|
107
|
+
"struct_item",
|
|
108
|
+
"enum_item",
|
|
109
|
+
"mod_item",
|
|
110
|
+
"type_item",
|
|
111
|
+
"const_item",
|
|
112
|
+
"static_item",
|
|
113
|
+
"macro_definition"
|
|
114
|
+
]),
|
|
115
|
+
java: new Set([
|
|
116
|
+
"class_declaration",
|
|
117
|
+
"interface_declaration",
|
|
118
|
+
"method_declaration",
|
|
119
|
+
"constructor_declaration",
|
|
120
|
+
"enum_declaration",
|
|
121
|
+
"field_declaration",
|
|
122
|
+
"annotation_type_declaration"
|
|
123
|
+
]),
|
|
124
|
+
c: new Set([
|
|
125
|
+
"function_definition",
|
|
126
|
+
"declaration",
|
|
127
|
+
"struct_specifier",
|
|
128
|
+
"enum_specifier",
|
|
129
|
+
"union_specifier",
|
|
130
|
+
"type_definition"
|
|
131
|
+
]),
|
|
132
|
+
cpp: new Set([
|
|
133
|
+
"function_definition",
|
|
134
|
+
"declaration",
|
|
135
|
+
"struct_specifier",
|
|
136
|
+
"class_specifier",
|
|
137
|
+
"enum_specifier",
|
|
138
|
+
"union_specifier",
|
|
139
|
+
"type_definition",
|
|
140
|
+
"namespace_definition",
|
|
141
|
+
"template_declaration"
|
|
142
|
+
])
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Node types that the AST exposes as "this token is an identifier".
|
|
146
|
+
* The match-position lookup uses these to filter out parent-node hits
|
|
147
|
+
* before checking the definition-site predicate.
|
|
148
|
+
*/
|
|
149
|
+
const IDENTIFIER_NODE_TYPES = new Set([
|
|
150
|
+
"identifier",
|
|
151
|
+
"type_identifier",
|
|
152
|
+
"field_identifier",
|
|
153
|
+
"property_identifier",
|
|
154
|
+
"shorthand_property_identifier_pattern",
|
|
155
|
+
"shorthand_property_identifier",
|
|
156
|
+
"scoped_identifier",
|
|
157
|
+
"name"
|
|
158
|
+
]);
|
|
159
|
+
let _grammarBundle;
|
|
160
|
+
/**
|
|
161
|
+
* Resolve the `tree-sitter-wasms/out/` directory at the package root.
|
|
162
|
+
* `require.resolve` is used through a try/catch — the bundled-only
|
|
163
|
+
* fallback runs in environments where node_modules has been pruned to
|
|
164
|
+
* just runtime deps.
|
|
165
|
+
*/
|
|
166
|
+
function resolveGrammarRoot() {
|
|
167
|
+
try {
|
|
168
|
+
const pkgPath = __require.resolve("tree-sitter-wasms/package.json");
|
|
169
|
+
return path.join(path.dirname(pkgPath), "out");
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Pre-load all grammars at module-init time so the first search
|
|
176
|
+
* doesn't pay a ~500ms cold-start cost. The Promise is captured at
|
|
177
|
+
* import time and awaited per-call; per-grammar failures are caught
|
|
178
|
+
* individually so one broken grammar can't take the whole tool down.
|
|
179
|
+
*/
|
|
180
|
+
function getGrammarBundle() {
|
|
181
|
+
if (_grammarBundle) return _grammarBundle;
|
|
182
|
+
_grammarBundle = { ready: (async () => {
|
|
183
|
+
const out = /* @__PURE__ */ new Map();
|
|
184
|
+
try {
|
|
185
|
+
await Parser.init();
|
|
186
|
+
} catch (err) {
|
|
187
|
+
consola.warn(`[code_search] tree-sitter Parser.init failed; structural ranking disabled: ${err.message}`);
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
const root = resolveGrammarRoot();
|
|
191
|
+
if (!root) {
|
|
192
|
+
consola.warn("[code_search] tree-sitter-wasms package not resolvable; structural ranking disabled");
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
for (const [key, filename] of Object.entries(GRAMMAR_FILES)) {
|
|
196
|
+
const wasmPath = path.join(root, filename);
|
|
197
|
+
try {
|
|
198
|
+
const lang = await Parser.Language.load(wasmPath);
|
|
199
|
+
out.set(key, lang);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
consola.warn(`[code_search] failed to load tree-sitter grammar '${key}' from ${filename}: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
})() };
|
|
206
|
+
return _grammarBundle;
|
|
207
|
+
}
|
|
208
|
+
getGrammarBundle().ready.catch(() => {});
|
|
209
|
+
/**
|
|
210
|
+
* Robustness bound on outline entries per file. Normal source is far
|
|
211
|
+
* under it; generated/pathological files hit it and `outlineFile` then
|
|
212
|
+
* sets a `notice` so the model knows the map was truncated.
|
|
213
|
+
*/
|
|
214
|
+
const MAX_OUTLINE_ENTRIES = 1e3;
|
|
215
|
+
/**
|
|
216
|
+
* First identifier-typed named child found in a pre-order walk. Mirrors
|
|
217
|
+
* the structural-pass helper in `code-search.ts` (kept local here so
|
|
218
|
+
* the grammar module has no dependency back on the ranking layer).
|
|
219
|
+
*/
|
|
220
|
+
function firstIdentifierLeaf(node) {
|
|
221
|
+
if (IDENTIFIER_NODE_TYPES.has(node.type)) return node;
|
|
222
|
+
for (const child of node.namedChildren) {
|
|
223
|
+
const r = firstIdentifierLeaf(child);
|
|
224
|
+
if (r) return r;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Derive a human-readable name for a definition node. Tries the
|
|
230
|
+
* grammar's standard `name` field first, then the `declarator` /
|
|
231
|
+
* `type` fields (C/C++/Java declarators, Rust/Go type specs), then any
|
|
232
|
+
* identifier-typed named child as a last resort. Returns `null` when no
|
|
233
|
+
* name can be recovered — the caller skips such nodes.
|
|
234
|
+
*/
|
|
235
|
+
function deriveDefinitionName(node) {
|
|
236
|
+
const nameField = node.childForFieldName("name");
|
|
237
|
+
if (nameField && nameField.text.length > 0) return nameField.text;
|
|
238
|
+
const declarator = node.childForFieldName("declarator");
|
|
239
|
+
if (declarator) {
|
|
240
|
+
const leaf = firstIdentifierLeaf(declarator);
|
|
241
|
+
if (leaf && leaf.text.length > 0) return leaf.text;
|
|
242
|
+
}
|
|
243
|
+
const typeField = node.childForFieldName("type");
|
|
244
|
+
if (typeField) {
|
|
245
|
+
const leaf = firstIdentifierLeaf(typeField);
|
|
246
|
+
if (leaf && leaf.text.length > 0) return leaf.text;
|
|
247
|
+
}
|
|
248
|
+
const fallback = firstIdentifierLeaf(node);
|
|
249
|
+
if (fallback && fallback.text.length > 0) return fallback.text;
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Collect EVERY definition node from the parse tree — top-level AND
|
|
254
|
+
* nested (class methods, methods' inner functions, nested classes, …) —
|
|
255
|
+
* so the outline is a COMPLETE structural map the model can rely on to
|
|
256
|
+
* decide what to read. Recurses through non-definition wrappers (TS
|
|
257
|
+
* `export_statement`, Python `decorated_definition`, C++
|
|
258
|
+
* `template_declaration`, …) at the same depth, and INTO each definition
|
|
259
|
+
* at depth+1 to surface its members.
|
|
260
|
+
*
|
|
261
|
+
* `defTypes` is the language's definition-node-type set. Each node yields
|
|
262
|
+
* one entry; the `name` is derived per `deriveDefinitionName` (a node
|
|
263
|
+
* with no recoverable name is skipped, but the walk still descends into
|
|
264
|
+
* it so its named members aren't lost). Bounded at `MAX_OUTLINE_ENTRIES`.
|
|
265
|
+
*/
|
|
266
|
+
function collectDefinitions(root, defTypes, signal) {
|
|
267
|
+
const out = [];
|
|
268
|
+
const visit = (node, depth) => {
|
|
269
|
+
if (signal?.aborted || out.length >= MAX_OUTLINE_ENTRIES) return;
|
|
270
|
+
for (const child of node.namedChildren) {
|
|
271
|
+
if (signal?.aborted || out.length >= MAX_OUTLINE_ENTRIES) return;
|
|
272
|
+
if (defTypes.has(child.type)) {
|
|
273
|
+
const name = deriveDefinitionName(child);
|
|
274
|
+
if (name !== null) out.push({
|
|
275
|
+
kind: child.type,
|
|
276
|
+
name,
|
|
277
|
+
line: child.startPosition.row + 1,
|
|
278
|
+
depth
|
|
279
|
+
});
|
|
280
|
+
visit(child, depth + 1);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
visit(child, depth);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
visit(root, 0);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Build a `FileOutlineResult` from an ALREADY-PARSED tree — walk-only,
|
|
291
|
+
* no read / parse / `delete`. The tree's ownership stays with the caller
|
|
292
|
+
* (e.g. the code-search structural pass's `_treeCache`), so this lets the
|
|
293
|
+
* outline step REUSE a tree the structural pass already parsed instead of
|
|
294
|
+
* re-reading + re-parsing the file. Never throws.
|
|
295
|
+
*/
|
|
296
|
+
function outlineFromTree(tree, language, signal) {
|
|
297
|
+
if (signal?.aborted) return {
|
|
298
|
+
outline: [],
|
|
299
|
+
language
|
|
300
|
+
};
|
|
301
|
+
const defTypes = DEFINITION_NODE_TYPES[language];
|
|
302
|
+
if (!defTypes) return {
|
|
303
|
+
outline: [],
|
|
304
|
+
language,
|
|
305
|
+
notice: "outline unavailable (parse error)"
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
const outline = collectDefinitions(tree.rootNode, defTypes, signal);
|
|
309
|
+
if (signal?.aborted) return {
|
|
310
|
+
outline: [],
|
|
311
|
+
language
|
|
312
|
+
};
|
|
313
|
+
outline.sort((a, b) => a.line - b.line);
|
|
314
|
+
if (outline.length >= MAX_OUTLINE_ENTRIES) return {
|
|
315
|
+
outline,
|
|
316
|
+
language,
|
|
317
|
+
notice: `outline truncated at ${MAX_OUTLINE_ENTRIES} symbols (very large file)`
|
|
318
|
+
};
|
|
319
|
+
return {
|
|
320
|
+
outline,
|
|
321
|
+
language
|
|
322
|
+
};
|
|
323
|
+
} catch {
|
|
324
|
+
return {
|
|
325
|
+
outline: [],
|
|
326
|
+
language,
|
|
327
|
+
notice: "outline unavailable (parse error)"
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Compute the absolute byte offset where line `lineNumber1` starts in
|
|
333
|
+
* `source`. Lines are counted by LF; CRLF files have the same line starts as
|
|
334
|
+
* LF files (the \r is part of the previous line's content). 1-indexed to match
|
|
335
|
+
* ripgrep. Returns -1 if the line is past EOF.
|
|
336
|
+
*/
|
|
337
|
+
function lineStartByte(source, lineNumber1) {
|
|
338
|
+
if (lineNumber1 <= 1) return 0;
|
|
339
|
+
let line = 1;
|
|
340
|
+
for (let i = 0; i < source.length; i++) if (source.charCodeAt(i) === 10) {
|
|
341
|
+
line += 1;
|
|
342
|
+
if (line === lineNumber1) return i + 1;
|
|
343
|
+
}
|
|
344
|
+
return -1;
|
|
345
|
+
}
|
|
346
|
+
function containsByteRange(outer, inner) {
|
|
347
|
+
return outer.startIndex <= inner.startIndex && outer.endIndex >= inner.endIndex;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Walk up from a matched identifier node looking for the closest
|
|
351
|
+
* definition-shape ancestor (per the language's allowed types). When found,
|
|
352
|
+
* verify the matched identifier is at the definition's "name" slot — NOT inside
|
|
353
|
+
* a parameter type, a body, or a parent's signature. Returns true iff this is a
|
|
354
|
+
* real definition site for the identifier the rg submatch landed on. Depth
|
|
355
|
+
* bound 6 — definition names sit close to their definition node in every
|
|
356
|
+
* supported grammar; deeper walks risk false positives.
|
|
357
|
+
*/
|
|
358
|
+
function isDefiningSite(matchedNode, langKey) {
|
|
359
|
+
const defTypes = DEFINITION_NODE_TYPES[langKey];
|
|
360
|
+
if (!defTypes) return false;
|
|
361
|
+
let cur = matchedNode.parent;
|
|
362
|
+
let depth = 0;
|
|
363
|
+
while (cur && depth < 6) {
|
|
364
|
+
if (defTypes.has(cur.type)) {
|
|
365
|
+
const nameField = cur.childForFieldName("name");
|
|
366
|
+
if (nameField && containsByteRange(nameField, matchedNode)) return true;
|
|
367
|
+
const declarator = cur.childForFieldName("declarator");
|
|
368
|
+
if (declarator && containsByteRange(declarator, matchedNode)) {
|
|
369
|
+
const first = firstIdentifierLeaf(declarator);
|
|
370
|
+
if (first && first.startIndex === matchedNode.startIndex) return true;
|
|
371
|
+
}
|
|
372
|
+
const typeField = cur.childForFieldName("type");
|
|
373
|
+
if (typeField && containsByteRange(typeField, matchedNode)) {
|
|
374
|
+
const first = firstIdentifierLeaf(typeField);
|
|
375
|
+
if (first && first.startIndex === matchedNode.startIndex) return true;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
cur = cur.parent;
|
|
379
|
+
depth += 1;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Run the AST-confirmation walk over `hits` against an ALREADY-PARSED tree.
|
|
385
|
+
* Returns the subset of input indices whose matched identifier is at a real
|
|
386
|
+
* definition site. Borrowed tree — never deleted. Pure + deterministic: the
|
|
387
|
+
* output set is a function of (tree, source, hits) only, independent of call
|
|
388
|
+
* order, so the in-process path and the worker path return identical sets.
|
|
389
|
+
*
|
|
390
|
+
* Never throws — a per-hit walk failure just omits that index (matches the
|
|
391
|
+
* in-process pass's per-hit try/catch). `signal` short-circuits between hits.
|
|
392
|
+
*/
|
|
393
|
+
function confirmDefinitionSites(tree, source, language, hits, signal) {
|
|
394
|
+
const confirmed = [];
|
|
395
|
+
if (!DEFINITION_NODE_TYPES[language]) return confirmed;
|
|
396
|
+
for (let i = 0; i < hits.length; i++) {
|
|
397
|
+
if (signal?.aborted) break;
|
|
398
|
+
const hit = hits[i];
|
|
399
|
+
const lineStart = lineStartByte(source, hit.line);
|
|
400
|
+
if (lineStart < 0) continue;
|
|
401
|
+
const matchByteStart = lineStart + hit.matchStart;
|
|
402
|
+
const matchByteEnd = lineStart + hit.matchEnd;
|
|
403
|
+
let node;
|
|
404
|
+
try {
|
|
405
|
+
node = tree.rootNode.descendantForIndex(matchByteStart, matchByteEnd);
|
|
406
|
+
} catch {
|
|
407
|
+
node = null;
|
|
408
|
+
}
|
|
409
|
+
if (!node) continue;
|
|
410
|
+
if (!IDENTIFIER_NODE_TYPES.has(node.type)) {
|
|
411
|
+
let cur = node;
|
|
412
|
+
let depth = 0;
|
|
413
|
+
while (cur && !IDENTIFIER_NODE_TYPES.has(cur.type) && depth < 3) {
|
|
414
|
+
const leaf = firstIdentifierLeaf(cur);
|
|
415
|
+
if (leaf && leaf.startIndex === matchByteStart) {
|
|
416
|
+
cur = leaf;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
cur = cur.parent;
|
|
420
|
+
depth += 1;
|
|
421
|
+
}
|
|
422
|
+
node = cur;
|
|
423
|
+
}
|
|
424
|
+
if (!node || !IDENTIFIER_NODE_TYPES.has(node.type)) continue;
|
|
425
|
+
if (isDefiningSite(node, language)) confirmed.push(i);
|
|
426
|
+
}
|
|
427
|
+
return confirmed;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/lib/tree-sitter-pool/worker.ts
|
|
432
|
+
if (!parentPort) throw new Error("tree-sitter worker must be spawned via worker_threads");
|
|
433
|
+
const port = parentPort;
|
|
434
|
+
let grammars = /* @__PURE__ */ new Map();
|
|
435
|
+
const parsers = /* @__PURE__ */ new Map();
|
|
436
|
+
let cancelled = false;
|
|
437
|
+
function handleJob(job) {
|
|
438
|
+
if (process.env.GH_ROUTER_TS_WORKER_CRASH === "1") throw new Error("injected worker crash (test)");
|
|
439
|
+
if (cancelled) return {
|
|
440
|
+
id: job.id,
|
|
441
|
+
ok: true,
|
|
442
|
+
mtimeMs: job.mtimeMs
|
|
443
|
+
};
|
|
444
|
+
const lang = grammars.get(job.language);
|
|
445
|
+
if (!lang) return {
|
|
446
|
+
id: job.id,
|
|
447
|
+
ok: false,
|
|
448
|
+
error: "grammar not loaded"
|
|
449
|
+
};
|
|
450
|
+
let mtimeMs;
|
|
451
|
+
let size;
|
|
452
|
+
try {
|
|
453
|
+
const st = statSync(job.absPath);
|
|
454
|
+
mtimeMs = st.mtimeMs;
|
|
455
|
+
size = st.size;
|
|
456
|
+
} catch (err) {
|
|
457
|
+
return {
|
|
458
|
+
id: job.id,
|
|
459
|
+
ok: false,
|
|
460
|
+
error: `stat failed: ${err.message}`
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (size > STRUCTURAL_MAX_FILE_BYTES) return {
|
|
464
|
+
id: job.id,
|
|
465
|
+
ok: false,
|
|
466
|
+
error: "file too large"
|
|
467
|
+
};
|
|
468
|
+
let source;
|
|
469
|
+
try {
|
|
470
|
+
source = readFileSync(job.absPath, "utf8");
|
|
471
|
+
} catch (err) {
|
|
472
|
+
return {
|
|
473
|
+
id: job.id,
|
|
474
|
+
ok: false,
|
|
475
|
+
error: `read failed: ${err.message}`
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
let parser = parsers.get(job.language);
|
|
479
|
+
if (!parser) {
|
|
480
|
+
parser = new Parser();
|
|
481
|
+
parser.setLanguage(lang);
|
|
482
|
+
parsers.set(job.language, parser);
|
|
483
|
+
}
|
|
484
|
+
let tree = null;
|
|
485
|
+
try {
|
|
486
|
+
tree = parser.parse(source);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
return {
|
|
489
|
+
id: job.id,
|
|
490
|
+
ok: false,
|
|
491
|
+
error: `parse failed: ${err.message}`
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (!tree) return {
|
|
495
|
+
id: job.id,
|
|
496
|
+
ok: false,
|
|
497
|
+
error: "parse returned null"
|
|
498
|
+
};
|
|
499
|
+
try {
|
|
500
|
+
const reply = {
|
|
501
|
+
id: job.id,
|
|
502
|
+
ok: true,
|
|
503
|
+
mtimeMs
|
|
504
|
+
};
|
|
505
|
+
if (job.want.confirmHits && job.want.confirmHits.length > 0) reply.confirmedHitIndexes = confirmDefinitionSites(tree, source, job.language, job.want.confirmHits);
|
|
506
|
+
if (job.want.outline) reply.outlineEntries = outlineFromTree(tree, job.language).outline;
|
|
507
|
+
return reply;
|
|
508
|
+
} catch (err) {
|
|
509
|
+
return {
|
|
510
|
+
id: job.id,
|
|
511
|
+
ok: false,
|
|
512
|
+
error: `walk failed: ${err.message}`
|
|
513
|
+
};
|
|
514
|
+
} finally {
|
|
515
|
+
try {
|
|
516
|
+
tree.delete();
|
|
517
|
+
} catch {}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
port.on("message", (msg) => {
|
|
521
|
+
if ("type" in msg) {
|
|
522
|
+
if (msg.type === "cancel") cancelled = true;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
cancelled = false;
|
|
526
|
+
const reply = handleJob(msg);
|
|
527
|
+
port.postMessage(reply);
|
|
528
|
+
});
|
|
529
|
+
(async () => {
|
|
530
|
+
try {
|
|
531
|
+
grammars = await getGrammarBundle().ready;
|
|
532
|
+
} catch {
|
|
533
|
+
grammars = /* @__PURE__ */ new Map();
|
|
534
|
+
}
|
|
535
|
+
const ready = {
|
|
536
|
+
type: "ready",
|
|
537
|
+
loaded: [...grammars.keys()]
|
|
538
|
+
};
|
|
539
|
+
port.postMessage(ready);
|
|
540
|
+
})();
|
|
541
|
+
|
|
542
|
+
//#endregion
|
|
543
|
+
export { };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import "./paths-yJ97KlKp.js";
|
|
2
|
+
import { a as trackChild, i as sweepStaleColbertMetaAtBoot, n as registerColbertExitHandlers, r as sweepLiveChildren, t as getColbertInstanceUuid } from "./lifecycle-yaqqtsV1.js";
|
|
3
|
+
|
|
4
|
+
export { getColbertInstanceUuid, registerColbertExitHandlers, sweepLiveChildren, sweepStaleColbertMetaAtBoot, trackChild };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { l as writeRuntimeFileSecure, t as PATHS } from "./paths-
|
|
1
|
+
import { l as writeRuntimeFileSecure, t as PATHS } from "./paths-yJ97KlKp.js";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
@@ -307,4 +307,4 @@ async function sweepStaleWorktreesAtBoot() {
|
|
|
307
307
|
|
|
308
308
|
//#endregion
|
|
309
309
|
export { sweepRegistry as a, registerExitHandlers as i, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, WorktreeRegistry as t };
|
|
310
|
-
//# sourceMappingURL=lifecycle-
|
|
310
|
+
//# sourceMappingURL=lifecycle-CMPthagV.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle-CSzT74Yn.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"lifecycle-CMPthagV.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
|