token-pilot 0.30.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -4
  3. package/README.md +24 -0
  4. package/agents/tp-api-surface-tracker.md +1 -1
  5. package/agents/tp-audit-scanner.md +1 -1
  6. package/agents/tp-commit-writer.md +1 -1
  7. package/agents/tp-context-engineer.md +1 -1
  8. package/agents/tp-dead-code-finder.md +1 -1
  9. package/agents/tp-debugger.md +1 -1
  10. package/agents/tp-dep-health.md +1 -1
  11. package/agents/tp-doc-writer.md +1 -1
  12. package/agents/tp-history-explorer.md +1 -1
  13. package/agents/tp-impact-analyzer.md +1 -1
  14. package/agents/tp-incident-timeline.md +1 -1
  15. package/agents/tp-incremental-builder.md +1 -1
  16. package/agents/tp-migration-scout.md +1 -1
  17. package/agents/tp-onboard.md +1 -1
  18. package/agents/tp-performance-profiler.md +1 -1
  19. package/agents/tp-pr-reviewer.md +1 -1
  20. package/agents/tp-refactor-planner.md +1 -1
  21. package/agents/tp-review-impact.md +1 -1
  22. package/agents/tp-run.md +1 -1
  23. package/agents/tp-session-restorer.md +1 -1
  24. package/agents/tp-ship-coordinator.md +1 -1
  25. package/agents/tp-spec-writer.md +1 -1
  26. package/agents/tp-test-coverage-gapper.md +1 -1
  27. package/agents/tp-test-triage.md +1 -1
  28. package/agents/tp-test-writer.md +1 -1
  29. package/dist/ast-index/client.d.ts +17 -2
  30. package/dist/ast-index/client.js +233 -107
  31. package/dist/core/edit-prep-state.d.ts +42 -0
  32. package/dist/core/edit-prep-state.js +108 -0
  33. package/dist/handlers/explore-area.js +6 -1
  34. package/dist/handlers/read-for-edit.d.ts +5 -5
  35. package/dist/handlers/read-for-edit.js +188 -110
  36. package/dist/hooks/installer.js +18 -0
  37. package/dist/hooks/pre-bash.d.ts +9 -0
  38. package/dist/hooks/pre-bash.js +48 -0
  39. package/dist/hooks/pre-edit.d.ts +69 -0
  40. package/dist/hooks/pre-edit.js +104 -0
  41. package/dist/hooks/pre-grep.d.ts +10 -0
  42. package/dist/hooks/pre-grep.js +38 -2
  43. package/dist/index.d.ts +30 -0
  44. package/dist/index.js +83 -20
  45. package/dist/server/tool-definitions.js +18 -6
  46. package/dist/server.js +21 -5
  47. package/docs/installation.md +27 -1
  48. package/hooks/hooks.json +18 -0
  49. package/package.json +1 -1
  50. package/start.sh +19 -9
@@ -1,12 +1,12 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- import { findBinary, installBinary } from './binary-manager.js';
4
- import { parseFileCount, parseOutlineText, parseImportsText, parseImplementationsText, parseHierarchyText, parseAgrepText, parseTodoText, parseDeprecatedText, parseAnnotationsText, parseModuleListText, parseModuleDepText, parseUnusedDepsText, parseModuleApiText, } from './parser.js';
5
- import { buildFileStructure } from './enricher.js';
6
- import { parseTypeScriptRegex } from './regex-parser.js';
7
- import { parsePythonRegex } from './regex-parser-python.js';
8
- const TS_JS_EXTENSIONS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
9
- const PYTHON_EXTENSIONS = new Set(['py', 'pyw']);
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { findBinary, installBinary } from "./binary-manager.js";
4
+ import { parseFileCount, parseOutlineText, parseImportsText, parseImplementationsText, parseHierarchyText, parseAgrepText, parseTodoText, parseDeprecatedText, parseAnnotationsText, parseModuleListText, parseModuleDepText, parseUnusedDepsText, parseModuleApiText, } from "./parser.js";
5
+ import { buildFileStructure } from "./enricher.js";
6
+ import { parseTypeScriptRegex } from "./regex-parser.js";
7
+ import { parsePythonRegex } from "./regex-parser-python.js";
8
+ const TS_JS_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
9
+ const PYTHON_EXTENSIONS = new Set(["py", "pyw"]);
10
10
  const execFileAsync = promisify(execFile);
11
11
  export class AstIndexClient {
12
12
  static MAX_INDEX_FILES = 50_000;
@@ -21,6 +21,9 @@ export class AstIndexClient {
21
21
  autoInstall;
22
22
  astGrepAvailable = null;
23
23
  astGrepBinDir = null;
24
+ // Periodic-update timer and overlap guard (see startPeriodicUpdate below)
25
+ periodicTimer = null;
26
+ periodicUpdateInFlight = false;
24
27
  constructor(projectRoot, timeout = 5000, options) {
25
28
  this.projectRoot = projectRoot;
26
29
  this.timeout = timeout;
@@ -37,7 +40,7 @@ export class AstIndexClient {
37
40
  }
38
41
  // 2. Auto-install if enabled
39
42
  if (this.autoInstall) {
40
- console.error('[token-pilot] ast-index not found, downloading...');
43
+ console.error("[token-pilot] ast-index not found, downloading...");
41
44
  try {
42
45
  const installed = await installBinary((msg) => console.error(`[token-pilot] ${msg}`));
43
46
  this.binaryPath = installed.path;
@@ -47,20 +50,20 @@ export class AstIndexClient {
47
50
  console.error(`[token-pilot] Auto-install failed: ${err instanceof Error ? err.message : err}`);
48
51
  }
49
52
  }
50
- throw new Error('ast-index binary not found and auto-install failed.\n' +
51
- 'Install manually: npx token-pilot install-ast-index\n' +
52
- 'Or: cargo install ast-index');
53
+ throw new Error("ast-index binary not found and auto-install failed.\n" +
54
+ "Install manually: npx token-pilot install-ast-index\n" +
55
+ "Or: cargo install ast-index");
53
56
  }
54
57
  async ensureIndex() {
55
58
  if (this.indexed)
56
59
  return;
57
60
  if (this.indexDisabled) {
58
- throw new Error('ast-index: index build disabled — project root is too broad (e.g. /). ' +
61
+ throw new Error("ast-index: index build disabled — project root is too broad (e.g. /). " +
59
62
  'Configure mcpServers with "args": ["/path/to/project"] to set the correct project root.');
60
63
  }
61
64
  if (this.indexOversized) {
62
- throw new Error('ast-index disabled: previous build indexed >50k files (likely node_modules). ' +
63
- 'Ensure node_modules is in .gitignore, then restart the MCP server.');
65
+ throw new Error("ast-index disabled: previous build indexed >50k files (likely node_modules). " +
66
+ "Ensure node_modules is in .gitignore, then restart the MCP server.");
64
67
  }
65
68
  // Deduplicate concurrent calls — all waiters share one build
66
69
  if (this.indexPromise)
@@ -76,26 +79,32 @@ export class AstIndexClient {
76
79
  async buildIndex() {
77
80
  let existingFileCount = 0;
78
81
  try {
79
- const stats = await this.exec(['--format', 'json', 'stats']);
82
+ const stats = await this.exec(["--format", "json", "stats"]);
80
83
  existingFileCount = parseFileCount(stats);
81
84
  }
82
- catch { /* no index yet */ }
85
+ catch {
86
+ /* no index yet */
87
+ }
83
88
  if (existingFileCount > AstIndexClient.MAX_INDEX_FILES) {
84
89
  console.error(`[token-pilot] ast-index: existing index has ${existingFileCount} files (>${AstIndexClient.MAX_INDEX_FILES}) — likely includes node_modules. Clearing.`);
85
90
  try {
86
- await this.exec(['clear']);
91
+ await this.exec(["clear"]);
92
+ }
93
+ catch {
94
+ /* best effort */
87
95
  }
88
- catch { /* best effort */ }
89
96
  existingFileCount = 0;
90
97
  }
91
98
  if (existingFileCount > 0) {
92
99
  console.error(`[token-pilot] ast-index: updating index (${existingFileCount} files)...`);
93
100
  try {
94
- await this.exec(['update'], 30000);
101
+ await this.exec(["update"], 30000);
95
102
  try {
96
- existingFileCount = parseFileCount(await this.exec(['--format', 'json', 'stats']));
103
+ existingFileCount = parseFileCount(await this.exec(["--format", "json", "stats"]));
104
+ }
105
+ catch {
106
+ /* keep previous count */
97
107
  }
98
- catch { /* keep previous count */ }
99
108
  if (existingFileCount > AstIndexClient.MAX_INDEX_FILES) {
100
109
  return this.handleOversizedIndex(existingFileCount);
101
110
  }
@@ -107,10 +116,10 @@ export class AstIndexClient {
107
116
  console.error(`[token-pilot] ast-index: update failed, falling back to rebuild — ${updateErr instanceof Error ? updateErr.message : updateErr}`);
108
117
  }
109
118
  }
110
- console.error('[token-pilot] ast-index: building index (this may take a moment)...');
119
+ console.error("[token-pilot] ast-index: building index (this may take a moment)...");
111
120
  try {
112
- await this.exec(['rebuild'], 120000);
113
- const fileCount = parseFileCount(await this.exec(['--format', 'json', 'stats']).catch(() => ''));
121
+ await this.exec(["rebuild"], 120000);
122
+ const fileCount = parseFileCount(await this.exec(["--format", "json", "stats"]).catch(() => ""));
114
123
  if (fileCount > AstIndexClient.MAX_INDEX_FILES) {
115
124
  return this.handleOversizedIndex(fileCount);
116
125
  }
@@ -119,8 +128,8 @@ export class AstIndexClient {
119
128
  }
120
129
  catch (buildErr) {
121
130
  const errMsg = buildErr instanceof Error ? buildErr.message : String(buildErr);
122
- if (errMsg.includes('lock') || errMsg.includes('already running')) {
123
- const count = parseFileCount(await this.exec(['--format', 'json', 'stats']).catch(() => ''));
131
+ if (errMsg.includes("lock") || errMsg.includes("already running")) {
132
+ const count = parseFileCount(await this.exec(["--format", "json", "stats"]).catch(() => ""));
124
133
  if (count > 0 && count <= AstIndexClient.MAX_INDEX_FILES) {
125
134
  this.indexed = true;
126
135
  console.error(`[token-pilot] ast-index: using existing index (${count} files, rebuild skipped due to lock)`);
@@ -138,9 +147,11 @@ export class AstIndexClient {
138
147
  this.indexOversized = true;
139
148
  this.indexed = false;
140
149
  try {
141
- await this.exec(['clear']);
150
+ await this.exec(["clear"]);
151
+ }
152
+ catch {
153
+ /* best effort */
142
154
  }
143
- catch { /* best effort */ }
144
155
  console.error(`[token-pilot] ast-index: ${fileCount} files indexed (>${AstIndexClient.MAX_INDEX_FILES}) — ` +
145
156
  `likely includes node_modules. Index cleared.\n` +
146
157
  ` → Ensure node_modules is in .gitignore\n` +
@@ -149,7 +160,7 @@ export class AstIndexClient {
149
160
  }
150
161
  async outline(filePath) {
151
162
  try {
152
- const result = await this.exec(['outline', filePath]);
163
+ const result = await this.exec(["outline", filePath]);
153
164
  const entries = parseOutlineText(result);
154
165
  if (entries.length === 0)
155
166
  return null;
@@ -160,7 +171,7 @@ export class AstIndexClient {
160
171
  return this.regexFallback(filePath);
161
172
  try {
162
173
  await this.ensureIndex();
163
- const result = await this.exec(['outline', filePath]);
174
+ const result = await this.exec(["outline", filePath]);
164
175
  const entries = parseOutlineText(result);
165
176
  if (entries.length === 0)
166
177
  return null;
@@ -174,15 +185,17 @@ export class AstIndexClient {
174
185
  }
175
186
  /** Regex-based fallback for TS/JS/Python when ast-index binary is unavailable. */
176
187
  async regexFallback(filePath) {
177
- const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
178
- const parser = TS_JS_EXTENSIONS.has(ext) ? parseTypeScriptRegex
179
- : PYTHON_EXTENSIONS.has(ext) ? parsePythonRegex
188
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
189
+ const parser = TS_JS_EXTENSIONS.has(ext)
190
+ ? parseTypeScriptRegex
191
+ : PYTHON_EXTENSIONS.has(ext)
192
+ ? parsePythonRegex
180
193
  : null;
181
194
  if (!parser)
182
195
  return null;
183
196
  try {
184
- const { readFile } = await import('node:fs/promises');
185
- const content = await readFile(filePath, 'utf-8');
197
+ const { readFile } = await import("node:fs/promises");
198
+ const content = await readFile(filePath, "utf-8");
186
199
  const entries = parser(content);
187
200
  if (entries.length === 0)
188
201
  return null;
@@ -194,24 +207,38 @@ export class AstIndexClient {
194
207
  }
195
208
  async symbol(name) {
196
209
  try {
197
- const result = await this.exec(['symbol', name, '--format', 'json']);
210
+ const result = await this.exec(["symbol", name, "--format", "json"]);
198
211
  const raw = JSON.parse(result);
199
212
  if (Array.isArray(raw) && raw.length > 0) {
200
213
  const first = raw[0];
201
- return { name: first.name, kind: first.kind, file: first.path, start_line: first.line, signature: first.signature };
214
+ return {
215
+ name: first.name,
216
+ kind: first.kind,
217
+ file: first.path,
218
+ start_line: first.line,
219
+ signature: first.signature,
220
+ };
202
221
  }
203
222
  }
204
- catch { /* fall through to ensureIndex path */ }
223
+ catch {
224
+ /* fall through to ensureIndex path */
225
+ }
205
226
  if (this.indexDisabled || this.indexOversized)
206
227
  return null;
207
228
  try {
208
229
  await this.ensureIndex();
209
- const result = await this.exec(['symbol', name, '--format', 'json']);
230
+ const result = await this.exec(["symbol", name, "--format", "json"]);
210
231
  const raw = JSON.parse(result);
211
232
  if (!Array.isArray(raw) || raw.length === 0)
212
233
  return null;
213
234
  const first = raw[0];
214
- return { name: first.name, kind: first.kind, file: first.path, start_line: first.line, signature: first.signature };
235
+ return {
236
+ name: first.name,
237
+ kind: first.kind,
238
+ file: first.path,
239
+ start_line: first.line,
240
+ signature: first.signature,
241
+ };
215
242
  }
216
243
  catch (err) {
217
244
  console.error(`[token-pilot] ast-index symbol failed: ${err instanceof Error ? err.message : err}`);
@@ -220,41 +247,51 @@ export class AstIndexClient {
220
247
  }
221
248
  async search(query, options) {
222
249
  await this.ensureIndex();
223
- const args = ['search', query, '--format', 'json'];
250
+ const args = ["search", query, "--format", "json"];
224
251
  if (options?.inFile)
225
- args.push('--in-file', options.inFile);
252
+ args.push("--in-file", options.inFile);
226
253
  if (options?.type)
227
- args.push('--type', options.type);
254
+ args.push("--type", options.type);
228
255
  if (options?.maxResults)
229
- args.push('--limit', String(options.maxResults));
256
+ args.push("--limit", String(options.maxResults));
230
257
  if (options?.fuzzy)
231
- args.push('--fuzzy');
258
+ args.push("--fuzzy");
232
259
  try {
233
260
  const result = await this.exec(args);
234
261
  const parsed = JSON.parse(result);
235
262
  // ast-index returns { content_matches: [], symbols: [], files: [], references: [] }
236
263
  // Merge all result types — content_matches alone is often empty
237
264
  const all = [
238
- ...(Array.isArray(parsed.content_matches) ? parsed.content_matches : []),
239
- ...(Array.isArray(parsed.symbols) ? parsed.symbols.map((s) => ({
240
- path: s.path ?? s.file, line: s.line, content: s.signature ?? s.name,
241
- })) : []),
242
- ...(Array.isArray(parsed.files) ? parsed.files.map((f) => ({
243
- path: f.path ?? f.file, line: f.line ?? 1, content: f.path ?? f.file,
244
- })) : []),
265
+ ...(Array.isArray(parsed.content_matches)
266
+ ? parsed.content_matches
267
+ : []),
268
+ ...(Array.isArray(parsed.symbols)
269
+ ? parsed.symbols.map((s) => ({
270
+ path: s.path ?? s.file,
271
+ line: s.line,
272
+ content: s.signature ?? s.name,
273
+ }))
274
+ : []),
275
+ ...(Array.isArray(parsed.files)
276
+ ? parsed.files.map((f) => ({
277
+ path: f.path ?? f.file,
278
+ line: f.line ?? 1,
279
+ content: f.path ?? f.file,
280
+ }))
281
+ : []),
245
282
  ...(Array.isArray(parsed.references) ? parsed.references : []),
246
283
  ];
247
- const matches = all.length > 0 ? all : (Array.isArray(parsed) ? parsed : []);
284
+ const matches = all.length > 0 ? all : Array.isArray(parsed) ? parsed : [];
248
285
  const mapped = matches
249
286
  .map((m) => ({
250
- file: m.path ?? m.file ?? '',
251
- line: typeof m.line === 'number' ? m.line : 0,
252
- text: m.content ?? m.text ?? m.signature ?? '',
287
+ file: m.path ?? m.file ?? "",
288
+ line: typeof m.line === "number" ? m.line : 0,
289
+ text: m.content ?? m.text ?? m.signature ?? "",
253
290
  }))
254
- .filter(r => r.file !== '' && r.text !== '');
291
+ .filter((r) => r.file !== "" && r.text !== "");
255
292
  // Deduplicate by file:line
256
293
  const seen = new Set();
257
- return mapped.filter(r => {
294
+ return mapped.filter((r) => {
258
295
  const key = `${r.file}:${r.line}`;
259
296
  if (seen.has(key))
260
297
  return false;
@@ -270,11 +307,21 @@ export class AstIndexClient {
270
307
  async usages(symbolName) {
271
308
  await this.ensureIndex();
272
309
  try {
273
- const result = await this.exec(['usages', symbolName, '--format', 'json']);
310
+ const result = await this.exec([
311
+ "usages",
312
+ symbolName,
313
+ "--format",
314
+ "json",
315
+ ]);
274
316
  const raw = JSON.parse(result);
275
317
  if (!Array.isArray(raw))
276
318
  return [];
277
- return raw.map(u => ({ file: u.path, line: u.line, text: u.context, kind: 'reference' }));
319
+ return raw.map((u) => ({
320
+ file: u.path,
321
+ line: u.line,
322
+ text: u.context,
323
+ kind: "reference",
324
+ }));
278
325
  }
279
326
  catch (err) {
280
327
  console.error(`[token-pilot] ast-index usages failed: ${err instanceof Error ? err.message : err}`);
@@ -284,7 +331,12 @@ export class AstIndexClient {
284
331
  async implementations(name) {
285
332
  await this.ensureIndex();
286
333
  try {
287
- const result = await this.exec(['implementations', name, '--format', 'json']);
334
+ const result = await this.exec([
335
+ "implementations",
336
+ name,
337
+ "--format",
338
+ "json",
339
+ ]);
288
340
  try {
289
341
  return JSON.parse(result);
290
342
  }
@@ -300,11 +352,11 @@ export class AstIndexClient {
300
352
  async hierarchy(name, options) {
301
353
  await this.ensureIndex();
302
354
  try {
303
- const args = ['hierarchy', name, '--format', 'json'];
355
+ const args = ["hierarchy", name, "--format", "json"];
304
356
  if (options?.inFile)
305
- args.push('--in-file', options.inFile);
357
+ args.push("--in-file", options.inFile);
306
358
  if (options?.module)
307
- args.push('--module', options.module);
359
+ args.push("--module", options.module);
308
360
  const result = await this.exec(args);
309
361
  try {
310
362
  return JSON.parse(result);
@@ -320,7 +372,7 @@ export class AstIndexClient {
320
372
  }
321
373
  async stats() {
322
374
  try {
323
- return await this.exec(['stats']);
375
+ return await this.exec(["stats"]);
324
376
  }
325
377
  catch {
326
378
  return null;
@@ -329,8 +381,11 @@ export class AstIndexClient {
329
381
  async listFiles() {
330
382
  try {
331
383
  await this.ensureIndex();
332
- const result = await this.exec(['files'], 15000);
333
- return result.split('\n').map(l => l.trim()).filter(l => l.length > 0);
384
+ const result = await this.exec(["files"], 15000);
385
+ return result
386
+ .split("\n")
387
+ .map((l) => l.trim())
388
+ .filter((l) => l.length > 0);
334
389
  }
335
390
  catch (err) {
336
391
  console.error(`[token-pilot] ast-index files failed: ${err instanceof Error ? err.message : err}`);
@@ -340,7 +395,14 @@ export class AstIndexClient {
340
395
  async refs(symbolName, limit = 20) {
341
396
  await this.ensureIndex();
342
397
  try {
343
- const result = await this.exec(['refs', symbolName, '--limit', String(limit), '--format', 'json']);
398
+ const result = await this.exec([
399
+ "refs",
400
+ symbolName,
401
+ "--limit",
402
+ String(limit),
403
+ "--format",
404
+ "json",
405
+ ]);
344
406
  return JSON.parse(result);
345
407
  }
346
408
  catch (err) {
@@ -351,11 +413,11 @@ export class AstIndexClient {
351
413
  async map(options) {
352
414
  await this.ensureIndex();
353
415
  try {
354
- const args = ['map', '--format', 'json'];
416
+ const args = ["map", "--format", "json"];
355
417
  if (options?.module)
356
- args.push('--module', options.module);
418
+ args.push("--module", options.module);
357
419
  if (options?.limit)
358
- args.push('--limit', String(options.limit));
420
+ args.push("--limit", String(options.limit));
359
421
  const result = await this.exec(args, 15000);
360
422
  return JSON.parse(result);
361
423
  }
@@ -367,7 +429,7 @@ export class AstIndexClient {
367
429
  async conventions() {
368
430
  await this.ensureIndex();
369
431
  try {
370
- const result = await this.exec(['conventions', '--format', 'json']);
432
+ const result = await this.exec(["conventions", "--format", "json"]);
371
433
  return JSON.parse(result);
372
434
  }
373
435
  catch (err) {
@@ -378,7 +440,14 @@ export class AstIndexClient {
378
440
  async callers(functionName, limit = 50) {
379
441
  await this.ensureIndex();
380
442
  try {
381
- const result = await this.exec(['callers', functionName, '--limit', String(limit), '--format', 'json']);
443
+ const result = await this.exec([
444
+ "callers",
445
+ functionName,
446
+ "--limit",
447
+ String(limit),
448
+ "--format",
449
+ "json",
450
+ ]);
382
451
  const parsed = JSON.parse(result);
383
452
  return Array.isArray(parsed) ? parsed : [];
384
453
  }
@@ -390,7 +459,14 @@ export class AstIndexClient {
390
459
  async callTree(functionName, depth = 3) {
391
460
  await this.ensureIndex();
392
461
  try {
393
- const result = await this.exec(['call-tree', functionName, '--depth', String(depth), '--format', 'json']);
462
+ const result = await this.exec([
463
+ "call-tree",
464
+ functionName,
465
+ "--depth",
466
+ String(depth),
467
+ "--format",
468
+ "json",
469
+ ]);
394
470
  return JSON.parse(result);
395
471
  }
396
472
  catch (err) {
@@ -401,9 +477,9 @@ export class AstIndexClient {
401
477
  async changed(base) {
402
478
  await this.ensureIndex();
403
479
  try {
404
- const args = ['changed', '--format', 'json'];
480
+ const args = ["changed", "--format", "json"];
405
481
  if (base)
406
- args.push('--base', base);
482
+ args.push("--base", base);
407
483
  const result = await this.exec(args, 15000);
408
484
  const parsed = JSON.parse(result);
409
485
  return Array.isArray(parsed) ? parsed : [];
@@ -416,13 +492,13 @@ export class AstIndexClient {
416
492
  async unusedSymbols(options) {
417
493
  await this.ensureIndex();
418
494
  try {
419
- const args = ['unused-symbols', '--format', 'json'];
495
+ const args = ["unused-symbols", "--format", "json"];
420
496
  if (options?.module)
421
- args.push('--module', options.module);
497
+ args.push("--module", options.module);
422
498
  if (options?.exportOnly)
423
- args.push('--export-only');
499
+ args.push("--export-only");
424
500
  if (options?.limit)
425
- args.push('--limit', String(options.limit));
501
+ args.push("--limit", String(options.limit));
426
502
  const result = await this.exec(args, 15000);
427
503
  const parsed = JSON.parse(result);
428
504
  return Array.isArray(parsed) ? parsed : [];
@@ -435,7 +511,7 @@ export class AstIndexClient {
435
511
  async fileImports(filePath) {
436
512
  await this.ensureIndex();
437
513
  try {
438
- const result = await this.exec(['imports', filePath]);
514
+ const result = await this.exec(["imports", filePath]);
439
515
  return parseImportsText(result);
440
516
  }
441
517
  catch (err) {
@@ -448,19 +524,26 @@ export class AstIndexClient {
448
524
  if (this.astGrepAvailable !== null)
449
525
  return this.astGrepAvailable;
450
526
  try {
451
- await execFileAsync('sg', ['--version'], { timeout: 3000 });
527
+ await execFileAsync("sg", ["--version"], { timeout: 3000 });
452
528
  this.astGrepAvailable = true;
453
529
  return true;
454
530
  }
455
- catch { /* not in PATH */ }
531
+ catch {
532
+ /* not in PATH */
533
+ }
456
534
  try {
457
- const localBinDir = new URL('../../node_modules/.bin', import.meta.url).pathname;
458
- await execFileAsync(localBinDir + '/sg', ['--version'], { timeout: 3000 });
535
+ const localBinDir = new URL("../../node_modules/.bin", import.meta.url)
536
+ .pathname;
537
+ await execFileAsync(localBinDir + "/sg", ["--version"], {
538
+ timeout: 3000,
539
+ });
459
540
  this.astGrepBinDir = localBinDir;
460
541
  this.astGrepAvailable = true;
461
542
  return true;
462
543
  }
463
- catch { /* not found locally either */ }
544
+ catch {
545
+ /* not found locally either */
546
+ }
464
547
  this.astGrepAvailable = false;
465
548
  return false;
466
549
  }
@@ -470,14 +553,14 @@ export class AstIndexClient {
470
553
  await this.ensureIndex();
471
554
  const available = await this.checkAstGrep();
472
555
  if (!available) {
473
- throw new Error('ast-grep (sg) not installed — required for structural pattern search.\n' +
474
- 'Install: brew install ast-grep OR npm i -g @ast-grep/cli\n' +
475
- 'Alternative: use Grep/ripgrep for text-based pattern search.');
556
+ throw new Error("ast-grep (sg) not installed — required for structural pattern search.\n" +
557
+ "Install: brew install ast-grep OR npm i -g @ast-grep/cli\n" +
558
+ "Alternative: use Grep/ripgrep for text-based pattern search.");
476
559
  }
477
560
  const limit = options?.limit ?? 50;
478
- const args = ['agrep', pattern];
561
+ const args = ["agrep", pattern];
479
562
  if (options?.lang)
480
- args.push('--lang', options.lang);
563
+ args.push("--lang", options.lang);
481
564
  try {
482
565
  const result = await this.exec(args, 15000);
483
566
  return parseAgrepText(result).slice(0, limit);
@@ -492,7 +575,7 @@ export class AstIndexClient {
492
575
  return [];
493
576
  await this.ensureIndex();
494
577
  try {
495
- const result = await this.exec(['todo'], 15000);
578
+ const result = await this.exec(["todo"], 15000);
496
579
  return parseTodoText(result);
497
580
  }
498
581
  catch (err) {
@@ -505,7 +588,7 @@ export class AstIndexClient {
505
588
  return [];
506
589
  await this.ensureIndex();
507
590
  try {
508
- const result = await this.exec(['deprecated'], 15000);
591
+ const result = await this.exec(["deprecated"], 15000);
509
592
  return parseDeprecatedText(result);
510
593
  }
511
594
  catch (err) {
@@ -518,7 +601,7 @@ export class AstIndexClient {
518
601
  return [];
519
602
  await this.ensureIndex();
520
603
  try {
521
- const result = await this.exec(['annotations', name], 15000);
604
+ const result = await this.exec(["annotations", name], 15000);
522
605
  return parseAnnotationsText(result, name);
523
606
  }
524
607
  catch (err) {
@@ -530,19 +613,51 @@ export class AstIndexClient {
530
613
  if (!this.indexed || this.indexDisabled || this.indexOversized)
531
614
  return;
532
615
  try {
533
- await this.exec(['update'], 15000);
616
+ await this.exec(["update"], 15000);
534
617
  }
535
618
  catch (err) {
536
619
  console.error(`[token-pilot] ast-index incremental update failed: ${err instanceof Error ? err.message : err}`);
537
620
  }
538
621
  }
622
+ /**
623
+ * Periodic safety-net so long sessions don't drift when FileWatcher misses
624
+ * events (Docker bind mounts, NFS, files changed by sibling tools). We
625
+ * explicitly avoid spawning `ast-index watch` as a daemon — it duplicates
626
+ * our FileWatcher, needs PID/lifecycle management, and goes zombie if the
627
+ * MCP server is killed with SIGKILL.
628
+ *
629
+ * Default cadence is 5 minutes. `unref()` lets the process exit naturally
630
+ * even if a tick is pending. An in-flight guard prevents overlapping runs
631
+ * when a single update exceeds the interval (rare — timeout is 15 s).
632
+ */
633
+ startPeriodicUpdate(intervalMs = 5 * 60 * 1000) {
634
+ if (this.periodicTimer)
635
+ return;
636
+ this.periodicTimer = setInterval(() => {
637
+ if (this.periodicUpdateInFlight)
638
+ return;
639
+ if (!this.indexed || this.indexDisabled || this.indexOversized)
640
+ return;
641
+ this.periodicUpdateInFlight = true;
642
+ void this.incrementalUpdate().finally(() => {
643
+ this.periodicUpdateInFlight = false;
644
+ });
645
+ }, intervalMs);
646
+ this.periodicTimer.unref?.();
647
+ }
648
+ stopPeriodicUpdate() {
649
+ if (this.periodicTimer) {
650
+ clearInterval(this.periodicTimer);
651
+ this.periodicTimer = null;
652
+ }
653
+ }
539
654
  // --- Module analysis methods ---
540
655
  async modules(pattern) {
541
656
  if (this.indexDisabled || this.indexOversized)
542
657
  return [];
543
658
  await this.ensureIndex();
544
659
  try {
545
- const cmdArgs = pattern ? ['module', pattern] : ['module'];
660
+ const cmdArgs = pattern ? ["module", pattern] : ["module"];
546
661
  const result = await this.exec(cmdArgs, 15000);
547
662
  return parseModuleListText(result);
548
663
  }
@@ -556,7 +671,7 @@ export class AstIndexClient {
556
671
  return [];
557
672
  await this.ensureIndex();
558
673
  try {
559
- const result = await this.exec(['deps', module], 15000);
674
+ const result = await this.exec(["deps", module], 15000);
560
675
  return parseModuleDepText(result);
561
676
  }
562
677
  catch (err) {
@@ -569,7 +684,7 @@ export class AstIndexClient {
569
684
  return [];
570
685
  await this.ensureIndex();
571
686
  try {
572
- const result = await this.exec(['dependents', module], 15000);
687
+ const result = await this.exec(["dependents", module], 15000);
573
688
  return parseModuleDepText(result);
574
689
  }
575
690
  catch (err) {
@@ -582,7 +697,7 @@ export class AstIndexClient {
582
697
  return [];
583
698
  await this.ensureIndex();
584
699
  try {
585
- const result = await this.exec(['unused-deps', module], 15000);
700
+ const result = await this.exec(["unused-deps", module], 15000);
586
701
  return parseUnusedDepsText(result);
587
702
  }
588
703
  catch (err) {
@@ -595,7 +710,7 @@ export class AstIndexClient {
595
710
  return [];
596
711
  await this.ensureIndex();
597
712
  try {
598
- const result = await this.exec(['api', module], 15000);
713
+ const result = await this.exec(["api", module], 15000);
599
714
  return parseModuleApiText(result);
600
715
  }
601
716
  catch (err) {
@@ -625,16 +740,27 @@ export class AstIndexClient {
625
740
  }
626
741
  async exec(args, timeoutMs) {
627
742
  if (!this.binaryPath) {
628
- throw new Error('ast-index not initialized. Call init() first.');
743
+ throw new Error("ast-index not initialized. Call init() first.");
744
+ }
745
+ // ast-index v3.39+ honours AST_INDEX_WALK_UP=1 — read-commands then
746
+ // traverse past nested VCS markers (submodule .git, inner Cargo.toml,
747
+ // nested settings.gradle) to reuse a parent-level index if one exists.
748
+ // Without this, running `search`/`outline` from a monorepo subdir stops
749
+ // at the nearest marker and finds nothing when the subdir has no DB.
750
+ // Safe default: pure-additive, no effect when projectRoot already sits
751
+ // at the index root.
752
+ const env = {
753
+ ...process.env,
754
+ AST_INDEX_WALK_UP: "1",
755
+ };
756
+ if (this.astGrepBinDir) {
757
+ env.PATH = `${this.astGrepBinDir}:${process.env.PATH ?? ""}`;
629
758
  }
630
- const env = this.astGrepBinDir
631
- ? { ...process.env, PATH: `${this.astGrepBinDir}:${process.env.PATH ?? ''}` }
632
- : undefined;
633
759
  const { stdout, stderr } = await execFileAsync(this.binaryPath, args, {
634
760
  timeout: timeoutMs ?? this.timeout,
635
761
  maxBuffer: 10 * 1024 * 1024, // 10MB
636
762
  cwd: this.projectRoot,
637
- ...(env && { env }),
763
+ env,
638
764
  });
639
765
  if (stderr) {
640
766
  console.error(`[token-pilot] ast-index stderr (${args[0]}): ${stderr.trim()}`);