context-mode 1.0.103 → 1.0.105

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 (98) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +39 -7
  6. package/bin/statusline.mjs +321 -0
  7. package/build/adapters/antigravity/index.d.ts +6 -0
  8. package/build/adapters/antigravity/index.js +10 -0
  9. package/build/adapters/base.d.ts +23 -0
  10. package/build/adapters/base.js +29 -0
  11. package/build/adapters/codex/index.d.ts +10 -0
  12. package/build/adapters/codex/index.js +22 -4
  13. package/build/adapters/cursor/index.d.ts +7 -0
  14. package/build/adapters/cursor/index.js +11 -0
  15. package/build/adapters/detect.d.ts +12 -1
  16. package/build/adapters/detect.js +69 -7
  17. package/build/adapters/gemini-cli/index.d.ts +8 -1
  18. package/build/adapters/gemini-cli/index.js +19 -7
  19. package/build/adapters/jetbrains-copilot/index.d.ts +7 -0
  20. package/build/adapters/jetbrains-copilot/index.js +12 -0
  21. package/build/adapters/kiro/index.d.ts +8 -0
  22. package/build/adapters/kiro/index.js +12 -0
  23. package/build/adapters/openclaw/index.d.ts +17 -0
  24. package/build/adapters/openclaw/index.js +29 -4
  25. package/build/adapters/opencode/index.d.ts +8 -0
  26. package/build/adapters/opencode/index.js +18 -6
  27. package/build/adapters/qwen-code/index.d.ts +1 -0
  28. package/build/adapters/qwen-code/index.js +3 -0
  29. package/build/adapters/types.d.ts +33 -0
  30. package/build/adapters/vscode-copilot/index.d.ts +6 -0
  31. package/build/adapters/vscode-copilot/index.js +10 -0
  32. package/build/adapters/zed/index.d.ts +1 -0
  33. package/build/adapters/zed/index.js +3 -0
  34. package/build/cli.d.ts +15 -0
  35. package/build/cli.js +62 -16
  36. package/build/concurrency/runPool.d.ts +36 -0
  37. package/build/concurrency/runPool.js +51 -0
  38. package/build/executor.d.ts +11 -1
  39. package/build/executor.js +77 -21
  40. package/build/fetch-cache.d.ts +13 -0
  41. package/build/fetch-cache.js +15 -0
  42. package/build/lifecycle.d.ts +6 -2
  43. package/build/lifecycle.js +29 -2
  44. package/build/opencode-plugin.d.ts +23 -0
  45. package/build/opencode-plugin.js +80 -6
  46. package/build/routing-block.d.ts +8 -0
  47. package/build/routing-block.js +86 -0
  48. package/build/runtime.d.ts +1 -0
  49. package/build/runtime.js +54 -3
  50. package/build/search/auto-memory.d.ts +23 -10
  51. package/build/search/auto-memory.js +64 -26
  52. package/build/search/unified.d.ts +3 -0
  53. package/build/search/unified.js +2 -2
  54. package/build/server.d.ts +47 -0
  55. package/build/server.js +736 -188
  56. package/build/session/analytics.d.ts +49 -1
  57. package/build/session/analytics.js +278 -16
  58. package/build/session/db.d.ts +53 -8
  59. package/build/session/db.js +200 -19
  60. package/build/session/extract.js +124 -2
  61. package/build/tool-naming.d.ts +4 -0
  62. package/build/tool-naming.js +24 -0
  63. package/cli.bundle.mjs +208 -158
  64. package/configs/antigravity/GEMINI.md +11 -0
  65. package/configs/claude-code/CLAUDE.md +11 -0
  66. package/configs/codex/AGENTS.md +11 -0
  67. package/configs/cursor/context-mode.mdc +11 -0
  68. package/configs/gemini-cli/GEMINI.md +11 -0
  69. package/configs/jetbrains-copilot/copilot-instructions.md +3 -0
  70. package/configs/kilo/AGENTS.md +11 -0
  71. package/configs/kiro/KIRO.md +11 -0
  72. package/configs/openclaw/AGENTS.md +11 -0
  73. package/configs/opencode/AGENTS.md +11 -0
  74. package/configs/pi/AGENTS.md +11 -0
  75. package/configs/qwen-code/QWEN.md +11 -0
  76. package/configs/vscode-copilot/copilot-instructions.md +3 -0
  77. package/configs/zed/AGENTS.md +11 -0
  78. package/hooks/auto-injection.mjs +36 -10
  79. package/hooks/cache-heal-utils.mjs +231 -0
  80. package/hooks/codex/sessionstart.mjs +7 -4
  81. package/hooks/core/routing.mjs +8 -2
  82. package/hooks/cursor/sessionstart.mjs +7 -4
  83. package/hooks/formatters/claude-code.mjs +20 -0
  84. package/hooks/gemini-cli/sessionstart.mjs +7 -2
  85. package/hooks/jetbrains-copilot/sessionstart.mjs +7 -2
  86. package/hooks/normalize-hooks.mjs +184 -0
  87. package/hooks/session-db.bundle.mjs +41 -14
  88. package/hooks/session-extract.bundle.mjs +2 -2
  89. package/hooks/session-helpers.mjs +68 -20
  90. package/hooks/session-loaders.mjs +8 -2
  91. package/hooks/sessionstart.mjs +8 -2
  92. package/hooks/vscode-copilot/sessionstart.mjs +7 -2
  93. package/openclaw.plugin.json +1 -1
  94. package/package.json +2 -1
  95. package/server.bundle.mjs +181 -134
  96. package/skills/ctx-doctor/SKILL.md +3 -3
  97. package/skills/ctx-insight/SKILL.md +1 -1
  98. package/start.mjs +63 -3
package/build/server.js CHANGED
@@ -3,15 +3,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { createHash } from "node:crypto";
6
- import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync } from "node:fs";
6
+ import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync } from "node:fs";
7
7
  import { execSync } from "node:child_process";
8
- import { join, dirname, resolve, sep } from "node:path";
8
+ import { join, dirname, resolve, sep, isAbsolute } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
- import { homedir, tmpdir } from "node:os";
10
+ import { homedir, tmpdir, cpus } from "node:os";
11
11
  import { request as httpsRequest } from "node:https";
12
12
  import { z } from "zod";
13
13
  import { PolyglotExecutor } from "./executor.js";
14
+ import { runPool } from "./concurrency/runPool.js";
14
15
  import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs } from "./store.js";
16
+ import { composeFetchCacheKey } from "./fetch-cache.js";
15
17
  import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, evaluateFilePath, } from "./security.js";
16
18
  import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
17
19
  import { classifyNonZeroExit } from "./exit-classify.js";
@@ -19,8 +21,9 @@ import { startLifecycleGuard } from "./lifecycle.js";
19
21
  import { getWorktreeSuffix, SessionDB } from "./session/db.js";
20
22
  import { searchAllSources } from "./search/unified.js";
21
23
  import { buildNodeCommand } from "./adapters/types.js";
24
+ import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
22
25
  import { loadDatabase } from "./db-base.js";
23
- import { AnalyticsEngine, formatReport } from "./session/analytics.js";
26
+ import { AnalyticsEngine, formatReport, getLifetimeStats, OPUS_INPUT_PRICE_PER_TOKEN } from "./session/analytics.js";
24
27
  const __pkg_dir = dirname(fileURLToPath(import.meta.url));
25
28
  const VERSION = (() => {
26
29
  for (const rel of ["../package.json", "./package.json"]) {
@@ -57,7 +60,7 @@ server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resou
57
60
  server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
58
61
  const executor = new PolyglotExecutor({
59
62
  runtimes,
60
- projectRoot: process.env.CLAUDE_PROJECT_DIR,
63
+ projectRoot: () => getProjectDir(),
61
64
  });
62
65
  // ─────────────────────────────────────────────────────────
63
66
  // FS read tracking preload for ctx_batch_execute
@@ -109,6 +112,20 @@ let _insightChild = null;
109
112
  function getSessionDir() {
110
113
  if (_detectedAdapter)
111
114
  return _detectedAdapter.getSessionDir();
115
+ // Pre-detection path (race window before MCP `initialize` completes):
116
+ // call detectPlatform() (sync, env-var-based) and look up segments via
117
+ // getSessionDirSegments() (sync map, no adapter instantiation). This keeps
118
+ // non-Claude platforms from spilling sessions into ~/.claude/.
119
+ try {
120
+ const signal = detectPlatform();
121
+ const segments = getSessionDirSegments(signal.platform);
122
+ if (segments) {
123
+ const dir = join(homedir(), ...segments, "context-mode", "sessions");
124
+ mkdirSync(dir, { recursive: true });
125
+ return dir;
126
+ }
127
+ }
128
+ catch { /* fall through to .claude fallback */ }
112
129
  const dir = join(homedir(), ".claude", "context-mode", "sessions");
113
130
  mkdirSync(dir, { recursive: true });
114
131
  return dir;
@@ -130,9 +147,18 @@ function getProjectDir() {
130
147
  || process.env.VSCODE_CWD
131
148
  || process.env.OPENCODE_PROJECT_DIR
132
149
  || process.env.PI_PROJECT_DIR
150
+ || process.env.IDEA_INITIAL_DIRECTORY
133
151
  || process.env.CONTEXT_MODE_PROJECT_DIR
134
152
  || process.cwd();
135
153
  }
154
+ /**
155
+ * Resolve a possibly-relative path against the project directory (full env cascade),
156
+ * not the MCP server's process.cwd(). MCP server is spawned by the host and its cwd
157
+ * is unrelated to where the user is working.
158
+ */
159
+ function resolveProjectPath(filePath) {
160
+ return isAbsolute(filePath) ? filePath : resolve(getProjectDir(), filePath);
161
+ }
136
162
  /**
137
163
  * Consistent project dir hashing across all DB paths.
138
164
  * Normalizes Windows backslashes before hashing so the same project
@@ -322,10 +348,120 @@ function trackResponse(toolName, response) {
322
348
  sessionStats.calls[toolName] = (sessionStats.calls[toolName] || 0) + 1;
323
349
  sessionStats.bytesReturned[toolName] =
324
350
  (sessionStats.bytesReturned[toolName] || 0) + bytes;
351
+ // Persist a sidecar JSON snapshot for the statusline — read at ~3-5 Hz by
352
+ // bin/statusline.mjs (and any external dashboard) so they don't have to
353
+ // open the SQLite database. Throttled inside persistStats() (500ms) so
354
+ // it's safe to call on every response. The b392c2f concurrency refactor
355
+ // dropped the SessionDB tool-call counter (`persistToolCallCounter`); we
356
+ // keep persistStats here because the statusline depends on it.
357
+ persistStats();
325
358
  return response;
326
359
  }
327
360
  function trackIndexed(bytes) {
328
361
  sessionStats.bytesIndexed += bytes;
362
+ persistStats();
363
+ }
364
+ // ─────────────────────────────────────────────────────────
365
+ // Stats persistence — written after every tool call so
366
+ // external readers (status line scripts, dashboards, hooks)
367
+ // can see real-time savings without spawning an MCP client.
368
+ // ─────────────────────────────────────────────────────────
369
+ const STATS_PERSIST_THROTTLE_MS = 500;
370
+ // Schema version for the persisted stats payload (~/.claude/context-mode/sessions/stats-*.json).
371
+ // Bump when a field is added/renamed/removed. Statusline reads `schemaVersion ?? 0` and warns when
372
+ // it sees a future schema, so legacy bundles degrade gracefully on upgrade rather than silently
373
+ // rendering missing fields (PR #401 architect review P1.3).
374
+ // v2: added tokens_saved_lifetime + dollars_saved_lifetime.
375
+ const STATS_SCHEMA_VERSION = 2;
376
+ // OPUS_INPUT_PRICE_PER_TOKEN intentionally NOT defined here — single source in
377
+ // src/session/analytics.ts re-exported above. (P1.1 — pricing constant dedup,
378
+ // PR #401 architect + ops 2-vote convergence.)
379
+ const LIFETIME_REFRESH_MS = 30_000;
380
+ // Matches the conversion factor in src/session/analytics.ts renderBottomLine:
381
+ // ~1KB per session event ÷ 4 bytes/token = 256 tokens/event.
382
+ const TOKENS_PER_EVENT = 256;
383
+ let _lastStatsPersist = 0;
384
+ let _lifetimeCache;
385
+ /**
386
+ * Resolve the per-session stats file path.
387
+ *
388
+ * The session id mirrors the Claude Code adapter contract
389
+ * (`pid-<parent pid>`), so a status line script can derive
390
+ * the same id from `$PPID` without coupling to MCP.
391
+ */
392
+ function getStatsFilePath() {
393
+ const sessionId = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
394
+ return join(getSessionDir(), `stats-${sessionId}.json`);
395
+ }
396
+ function persistStats() {
397
+ const now = Date.now();
398
+ if (now - _lastStatsPersist < STATS_PERSIST_THROTTLE_MS)
399
+ return;
400
+ _lastStatsPersist = now;
401
+ try {
402
+ const totalReturned = Object.values(sessionStats.bytesReturned).reduce((a, b) => a + b, 0);
403
+ const totalCalls = Object.values(sessionStats.calls).reduce((a, b) => a + b, 0);
404
+ const keptOut = sessionStats.bytesIndexed +
405
+ sessionStats.bytesSandboxed +
406
+ sessionStats.cacheBytesSaved;
407
+ const totalProcessed = keptOut + totalReturned;
408
+ const reductionPct = totalProcessed > 0
409
+ ? Math.round((1 - totalReturned / totalProcessed) * 100)
410
+ : 0;
411
+ const tokensSaved = Math.round(keptOut / 4);
412
+ // Lifetime savings — cached separately because getLifetimeStats() scans
413
+ // disk (per-project SessionDBs + auto-memory dirs) and is too expensive
414
+ // for the 500ms persist throttle. Refresh every 30s; the statusline
415
+ // doesn't need second-by-second lifetime accuracy.
416
+ let lifetimeTokens = _lifetimeCache?.tokens ?? 0;
417
+ if (!_lifetimeCache || now - _lifetimeCache.computedAt > LIFETIME_REFRESH_MS) {
418
+ try {
419
+ const life = getLifetimeStats({ sessionsDir: getSessionDir() });
420
+ lifetimeTokens = (life?.totalEvents ?? 0) * TOKENS_PER_EVENT;
421
+ _lifetimeCache = { tokens: lifetimeTokens, computedAt: now };
422
+ }
423
+ catch {
424
+ // best-effort — keep stale cache or 0
425
+ }
426
+ }
427
+ const payload = {
428
+ schemaVersion: STATS_SCHEMA_VERSION,
429
+ version: VERSION,
430
+ updated_at: now,
431
+ session_start: sessionStats.sessionStart,
432
+ uptime_ms: now - sessionStats.sessionStart,
433
+ total_calls: totalCalls,
434
+ bytes_returned: totalReturned,
435
+ bytes_indexed: sessionStats.bytesIndexed,
436
+ bytes_sandboxed: sessionStats.bytesSandboxed,
437
+ cache_hits: sessionStats.cacheHits,
438
+ cache_bytes_saved: sessionStats.cacheBytesSaved,
439
+ kept_out: keptOut,
440
+ total_processed: totalProcessed,
441
+ reduction_pct: reductionPct,
442
+ tokens_saved: tokensSaved,
443
+ // statusline-facing $ values — pre-computed at Opus input rate so the
444
+ // statusline doesn't have to know pricing. Lets us evolve pricing in
445
+ // one place without touching consumers.
446
+ dollars_saved_session: +(tokensSaved * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2),
447
+ tokens_saved_lifetime: lifetimeTokens,
448
+ dollars_saved_lifetime: +(lifetimeTokens * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2),
449
+ by_tool: Object.fromEntries(Object.keys({ ...sessionStats.calls, ...sessionStats.bytesReturned }).map((t) => [
450
+ t,
451
+ {
452
+ calls: sessionStats.calls[t] || 0,
453
+ bytes: sessionStats.bytesReturned[t] || 0,
454
+ },
455
+ ])),
456
+ };
457
+ const filePath = getStatsFilePath();
458
+ const tmpPath = `${filePath}.tmp`;
459
+ writeFileSync(tmpPath, JSON.stringify(payload));
460
+ renameSync(tmpPath, filePath);
461
+ }
462
+ catch {
463
+ // best-effort — never break tool calls because of stats persistence
464
+ }
329
465
  }
330
466
  // ==============================================================================
331
467
  // Security: server-side deny firewall
@@ -387,7 +523,7 @@ function checkNonShellDenyPolicy(code, language, toolName) {
387
523
  */
388
524
  function checkFilePathDenyPolicy(filePath, toolName) {
389
525
  try {
390
- const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
526
+ const projectDir = getProjectDir();
391
527
  const denyGlobs = readToolDenyPatterns("Read", projectDir);
392
528
  const result = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
393
529
  if (result.denied) {
@@ -539,6 +675,100 @@ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 *
539
675
  sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\`.`);
540
676
  return sections;
541
677
  }
678
+ function formatCommandOutput(label, raw, onFsBytes) {
679
+ let output = raw || "(no output)";
680
+ const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
681
+ let cmdFsBytes = 0;
682
+ for (const m of fsMatches)
683
+ cmdFsBytes += parseInt(m[1]);
684
+ if (cmdFsBytes > 0) {
685
+ onFsBytes?.(cmdFsBytes);
686
+ output = output.replace(/__CM_FS__:\d+\n?/g, "");
687
+ }
688
+ return `# ${label}\n\n${output}\n`;
689
+ }
690
+ /**
691
+ * Execute batch commands. concurrency=1 preserves the legacy serial path
692
+ * (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
693
+ * commands concurrently with at most N in flight; each command receives the
694
+ * full timeout, output is collated by input index, and per-command timeouts
695
+ * record `(timed out)` blocks without skipping siblings.
696
+ */
697
+ export async function runBatchCommands(commands, opts, executor) {
698
+ const { timeout, concurrency, nodeOptsPrefix, onFsBytes } = opts;
699
+ if (concurrency <= 1) {
700
+ // Serial path — shared timeout budget, cascading skip on timeout.
701
+ // When `timeout` is undefined, no shared budget is enforced; each
702
+ // command runs to completion (Issue #406).
703
+ const outputs = [];
704
+ const startTime = Date.now();
705
+ let timedOut = false;
706
+ for (let i = 0; i < commands.length; i++) {
707
+ const cmd = commands[i];
708
+ let perCmdTimeout;
709
+ if (timeout !== undefined) {
710
+ const elapsed = Date.now() - startTime;
711
+ const remaining = timeout - elapsed;
712
+ if (remaining <= 0) {
713
+ outputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`);
714
+ timedOut = true;
715
+ continue;
716
+ }
717
+ perCmdTimeout = remaining;
718
+ }
719
+ const result = await executor.execute({
720
+ language: "shell",
721
+ code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
722
+ timeout: perCmdTimeout,
723
+ });
724
+ outputs.push(formatCommandOutput(cmd.label, result.stdout, onFsBytes));
725
+ if (result.timedOut) {
726
+ timedOut = true;
727
+ for (let j = i + 1; j < commands.length; j++) {
728
+ outputs.push(`# ${commands[j].label}\n\n(skipped — batch timeout exceeded)\n`);
729
+ }
730
+ break;
731
+ }
732
+ }
733
+ return { outputs, timedOut };
734
+ }
735
+ // Parallel path — delegated to the shared runPool primitive.
736
+ // Each job returns { output, timedOut }; runPool handles in-flight cap,
737
+ // throw isolation (Promise.allSettled semantics), and order preservation.
738
+ const jobs = commands.map((cmd) => ({
739
+ run: async () => {
740
+ const result = await executor.execute({
741
+ language: "shell",
742
+ code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
743
+ timeout,
744
+ });
745
+ // Always route partial stdout through formatCommandOutput so __CM_FS__
746
+ // markers are stripped + counted, even when the command timed out.
747
+ const formatted = formatCommandOutput(cmd.label, result.stdout, onFsBytes);
748
+ const output = result.timedOut
749
+ ? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
750
+ : formatted;
751
+ return { output, timedOut: !!result.timedOut };
752
+ },
753
+ }));
754
+ const { settled } = await runPool(jobs, { concurrency });
755
+ const outputs = new Array(commands.length);
756
+ let timedOut = false;
757
+ for (let i = 0; i < settled.length; i++) {
758
+ const r = settled[i];
759
+ if (r.status === "fulfilled") {
760
+ outputs[i] = r.value.output;
761
+ if (r.value.timedOut)
762
+ timedOut = true;
763
+ }
764
+ else {
765
+ // Isolated executor throw (spawn EAGAIN, ENOMEM, EMFILE, …) — siblings keep running.
766
+ const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
767
+ outputs[i] = `# ${commands[i].label}\n\n(executor error: ${message})\n`;
768
+ }
769
+ }
770
+ return { outputs, timedOut };
771
+ }
542
772
  // ─────────────────────────────────────────────────────────
543
773
  // Tool: execute
544
774
  // ─────────────────────────────────────────────────────────
@@ -567,8 +797,7 @@ server.registerTool("ctx_execute", {
567
797
  timeout: z
568
798
  .coerce.number()
569
799
  .optional()
570
- .default(30000)
571
- .describe("Max execution time in ms"),
800
+ .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs (which is the right layer for this policy). Pass an explicit value for long-running builds (Gradle/Maven/SBT)."),
572
801
  background: z
573
802
  .boolean()
574
803
  .optional()
@@ -863,8 +1092,7 @@ server.registerTool("ctx_execute_file", {
863
1092
  timeout: z
864
1093
  .coerce.number()
865
1094
  .optional()
866
- .default(30000)
867
- .describe("Max execution time in ms"),
1095
+ .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs."),
868
1096
  intent: z
869
1097
  .string()
870
1098
  .optional()
@@ -1009,18 +1237,19 @@ server.registerTool("ctx_index", {
1009
1237
  });
1010
1238
  }
1011
1239
  try {
1240
+ const resolvedPath = path ? resolveProjectPath(path) : undefined;
1012
1241
  // Track the raw bytes being indexed (content or file)
1013
1242
  if (content)
1014
1243
  trackIndexed(Buffer.byteLength(content));
1015
- else if (path) {
1244
+ else if (resolvedPath) {
1016
1245
  try {
1017
1246
  const fs = await import("fs");
1018
- trackIndexed(fs.readFileSync(path).byteLength);
1247
+ trackIndexed(fs.readFileSync(resolvedPath).byteLength);
1019
1248
  }
1020
1249
  catch { /* ignore — file read errors handled by store */ }
1021
1250
  }
1022
1251
  const store = getStore();
1023
- const result = store.index({ content, path, source });
1252
+ const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath });
1024
1253
  return trackResponse("ctx_index", {
1025
1254
  content: [
1026
1255
  {
@@ -1178,14 +1407,14 @@ server.registerTool("ctx_search", {
1178
1407
  if (sort === "timeline") {
1179
1408
  try {
1180
1409
  const sessionsDir = getSessionDir();
1181
- const dbFile = join(sessionsDir, `${hashProjectDir()}.db`);
1410
+ const dbFile = join(sessionsDir, `${hashProjectDir()}${getWorktreeSuffix()}.db`);
1182
1411
  if (existsSync(dbFile)) {
1183
1412
  timelineDB = new SessionDB({ dbPath: dbFile });
1184
1413
  }
1185
1414
  }
1186
1415
  catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
1187
1416
  }
1188
- const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
1417
+ const configDir = _detectedAdapter?.getConfigDir() ?? (process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
1189
1418
  try {
1190
1419
  for (const q of queryList) {
1191
1420
  if (totalSize > MAX_TOTAL) {
@@ -1204,6 +1433,7 @@ server.registerTool("ctx_search", {
1204
1433
  sessionDB: timelineDB,
1205
1434
  projectDir: getProjectDir(),
1206
1435
  configDir,
1436
+ adapter: _detectedAdapter ?? undefined,
1207
1437
  });
1208
1438
  }
1209
1439
  else {
@@ -1342,54 +1572,178 @@ async function main() {
1342
1572
  main();
1343
1573
  `;
1344
1574
  }
1345
- server.registerTool("ctx_fetch_and_index", {
1346
- title: "Fetch & Index URL",
1347
- description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
1348
- "and returns a ~3KB preview. Full content stays in sandbox use ctx_search() for deeper lookups.\n\n" +
1349
- "Better than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context.\n\n" +
1350
- "Content-type aware: HTML is converted to markdown, JSON is chunked by key paths, plain text is indexed directly.\n\n" +
1351
- "When reporting results terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
1352
- inputSchema: z.object({
1353
- url: z.string().describe("The URL to fetch and index"),
1354
- source: z
1355
- .string()
1356
- .optional()
1357
- .describe("Label for the indexed content (e.g., 'React useEffect docs', 'Supabase Auth API')"),
1358
- force: z
1359
- .boolean()
1360
- .optional()
1361
- .describe("Skip cache and re-fetch even if content was recently indexed"),
1362
- }),
1363
- }, async ({ url, source, force }) => {
1364
- // TTL cache: if source was indexed within 24h, return cached hint
1575
+ // ─────────────────────────────────────────────────────────
1576
+ // fetch_and_index helpers split into parallel-safe fetch and serial-only index
1577
+ // ─────────────────────────────────────────────────────────
1578
+ const FETCH_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
1579
+ const FETCH_PREVIEW_LIMIT = 3072;
1580
+ /**
1581
+ * Pure fetch step TTL cache check + subprocess fetch. SAFE TO RUN IN PARALLEL.
1582
+ * Performs zero SQLite writes (only reads source meta). Caller must funnel
1583
+ * fetched results through `indexFetched` serially to avoid FTS5 WAL contention.
1584
+ */
1585
+ /**
1586
+ * SSRF guard for ctx_fetch_and_index: validate URL scheme + resolve target IP +
1587
+ * block link-local / IMDS / multicast / reserved IP ranges. Returns null if
1588
+ * safe; returns a FetchOneResult fetch_error if blocked.
1589
+ *
1590
+ * Policy (PR #401 ops review, developer-friendly default):
1591
+ *
1592
+ * **HARD BLOCK** (no legitimate dev workflow):
1593
+ * - file://, gopher://, javascript:, data: schemes (only http: and https:)
1594
+ * - 169.254.0.0/16 link-local (INCLUDES 169.254.169.254 = AWS/GCP/Azure IMDS
1595
+ * cloud credential endpoint — high-value target for indirect prompt injection)
1596
+ * - IPv6 link-local fe80::/10
1597
+ * - Multicast (224+ IPv4, ff00::/8 IPv6) and reserved (0.0.0.0/8) ranges
1598
+ *
1599
+ * **ALLOW by default** (legitimate developer use cases dominate):
1600
+ * - localhost, 127.x.x.x, ::1 (local dev servers — Next.js, Vite, Postgres, …)
1601
+ * - 10.x, 172.16-31.x, 192.168.x RFC1918 private (developer's internal network)
1602
+ *
1603
+ * **STRICT MODE** opt-in via env var: `CTX_FETCH_STRICT=1`
1604
+ * - Blocks loopback + RFC1918 too
1605
+ * - For hosted/CI environments where the runtime isn't the user's own machine
1606
+ *
1607
+ * DNS resolution is performed against the resolved IP (not just URL parse) so a
1608
+ * hostname like `evil.com` pointing to 169.254.169.254 is rejected — defends
1609
+ * against attacker-controlled DNS records and DNS rebinding.
1610
+ */
1611
+ async function ssrfGuard(rawUrl) {
1612
+ let parsed;
1613
+ try {
1614
+ parsed = new URL(rawUrl);
1615
+ }
1616
+ catch {
1617
+ return { kind: "fetch_error", url: rawUrl, error: "invalid URL", reason: "exit" };
1618
+ }
1619
+ // 1. Scheme allowlist — http and https only
1620
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1621
+ return {
1622
+ kind: "fetch_error",
1623
+ url: rawUrl,
1624
+ error: `URL scheme "${parsed.protocol}" not allowed (only http: and https:)`,
1625
+ reason: "exit",
1626
+ };
1627
+ }
1628
+ const strict = process.env.CTX_FETCH_STRICT === "1";
1629
+ // 2. DNS resolve + check IP ranges (hard-block + optional strict-mode block)
1630
+ try {
1631
+ const { lookup } = await import("node:dns/promises");
1632
+ const records = await lookup(parsed.hostname, { all: true, verbatim: true });
1633
+ for (const rec of records) {
1634
+ const verdict = classifyIp(rec.address);
1635
+ if (verdict === "block") {
1636
+ return {
1637
+ kind: "fetch_error",
1638
+ url: rawUrl,
1639
+ error: `URL "${parsed.hostname}" resolves to ${rec.address} — blocked (link-local / IMDS / multicast / reserved)`,
1640
+ reason: "exit",
1641
+ };
1642
+ }
1643
+ if (verdict === "private" && strict) {
1644
+ return {
1645
+ kind: "fetch_error",
1646
+ url: rawUrl,
1647
+ error: `URL "${parsed.hostname}" resolves to private IP ${rec.address} — blocked under CTX_FETCH_STRICT=1`,
1648
+ reason: "exit",
1649
+ };
1650
+ }
1651
+ }
1652
+ }
1653
+ catch (err) {
1654
+ return {
1655
+ kind: "fetch_error",
1656
+ url: rawUrl,
1657
+ error: `DNS lookup failed for "${parsed.hostname}": ${err instanceof Error ? err.message : String(err)}`,
1658
+ reason: "exit",
1659
+ };
1660
+ }
1661
+ return null; // safe to fetch
1662
+ }
1663
+ /**
1664
+ * Classify an IP address.
1665
+ * - "block": always blocked (link-local/IMDS/multicast/reserved/malformed)
1666
+ * - "private": loopback or RFC1918 — allowed by default, blocked in strict mode
1667
+ * - "public": safe to fetch
1668
+ *
1669
+ * Exported (via the function name) so SSRF tests can exercise the matcher directly.
1670
+ */
1671
+ export function classifyIp(ip) {
1672
+ const lower = ip.toLowerCase();
1673
+ // IPv6 takes priority — check for `:` first so IPv4-mapped addresses
1674
+ // (`::ffff:127.0.0.1`) don't get incorrectly routed through the IPv4 parser.
1675
+ if (lower.includes(":")) {
1676
+ // IPv4-mapped IPv6 (`::ffff:127.0.0.1`) — recurse through IPv4 classifier
1677
+ const v4MappedMatch = lower.match(/^::ffff:([\d.]+)$/);
1678
+ if (v4MappedMatch)
1679
+ return classifyIp(v4MappedMatch[1]);
1680
+ // Hard-block
1681
+ if (lower === "::")
1682
+ return "block"; // unspecified
1683
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") ||
1684
+ lower.startsWith("fea") || lower.startsWith("feb"))
1685
+ return "block"; // fe80::/10 link-local
1686
+ if (lower.startsWith("ff"))
1687
+ return "block"; // ff00::/8 multicast
1688
+ // Private (loopback + ULA)
1689
+ if (lower === "::1")
1690
+ return "private";
1691
+ if (lower.startsWith("fc") || lower.startsWith("fd"))
1692
+ return "private"; // fc00::/7 ULA
1693
+ return "public";
1694
+ }
1695
+ // IPv4 (or non-IP string — malformed = block)
1696
+ if (!ip.includes("."))
1697
+ return "block"; // not an IP at all
1698
+ const parts = ip.split(".").map((p) => parseInt(p, 10));
1699
+ if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255))
1700
+ return "block";
1701
+ const [a, b] = parts;
1702
+ // Hard-block (no legitimate use)
1703
+ if (a === 169 && b === 254)
1704
+ return "block"; // link-local incl. 169.254.169.254 (IMDS)
1705
+ if (a === 0)
1706
+ return "block"; // 0.0.0.0/8 (current network)
1707
+ if (a >= 224)
1708
+ return "block"; // 224.0.0.0+ multicast/reserved
1709
+ // Private (loopback + RFC1918) — allow by default
1710
+ if (a === 127)
1711
+ return "private"; // 127.0.0.0/8 loopback
1712
+ if (a === 10)
1713
+ return "private"; // 10.0.0.0/8
1714
+ if (a === 172 && b >= 16 && b <= 31)
1715
+ return "private"; // 172.16.0.0/12
1716
+ if (a === 192 && b === 168)
1717
+ return "private"; // 192.168.0.0/16
1718
+ return "public";
1719
+ }
1720
+ async function fetchOneUrl(url, source, force) {
1721
+ // SSRF guard — reject file://, javascript:, loopback, RFC1918, IMDS, link-local
1722
+ // BEFORE any cache lookup or subprocess spawn. Even cached entries shouldn't
1723
+ // serve a previously-poisoned source label.
1724
+ const ssrfBlock = await ssrfGuard(url);
1725
+ if (ssrfBlock)
1726
+ return ssrfBlock;
1365
1727
  if (!force) {
1366
1728
  const store = getStore();
1367
- const label = source ?? url;
1368
- const meta = store.getSourceMeta(label);
1729
+ // Cache key composes (source, url) so two distinct URLs sharing the same
1730
+ // `source` label do not collide — they each get their own cache slot
1731
+ // (commit 1f1243e regression test enforced).
1732
+ const cacheKey = composeFetchCacheKey(source, url);
1733
+ const meta = store.getSourceMeta(cacheKey);
1369
1734
  if (meta) {
1370
1735
  const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
1371
1736
  const ageMs = Date.now() - indexedAt.getTime();
1372
- const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
1373
- if (ageMs < TTL_MS) {
1737
+ if (ageMs < FETCH_TTL_MS) {
1374
1738
  const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
1375
1739
  const ageMin = Math.floor(ageMs / (60 * 1000));
1376
1740
  const ageStr = ageHours > 0 ? `${ageHours}h ago` : ageMin > 0 ? `${ageMin}m ago` : "just now";
1377
- // Track cache savings estimate ~1.6KB per chunk (average indexed content size)
1378
- const estimatedBytes = meta.chunkCount * 1600;
1379
- sessionStats.cacheHits++;
1380
- sessionStats.cacheBytesSaved += estimatedBytes;
1381
- return trackResponse("ctx_fetch_and_index", {
1382
- content: [{
1383
- type: "text",
1384
- text: `Cached: **${meta.label}** — ${meta.chunkCount} sections, indexed ${ageStr} (fresh, TTL: 24h).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${meta.label}")`,
1385
- }],
1386
- });
1741
+ const estimatedBytes = meta.chunkCount * 1600; // ~1.6KB/chunk avg
1742
+ return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr };
1387
1743
  }
1388
- // Stale (>24h) — fall through to re-fetch silently
1744
+ // Stale — fall through to re-fetch silently
1389
1745
  }
1390
1746
  }
1391
- // Generate a unique temp file path for the subprocess to write fetched content.
1392
- // This bypasses the executor's 100KB stdout truncation — content goes file→handler directly.
1393
1747
  const outputPath = join(tmpdir(), `ctx-fetch-${Date.now()}-${Math.random().toString(36).slice(2)}.dat`);
1394
1748
  try {
1395
1749
  const fetchCode = buildFetchCode(url, outputPath);
@@ -1399,93 +1753,258 @@ server.registerTool("ctx_fetch_and_index", {
1399
1753
  timeout: 30_000,
1400
1754
  });
1401
1755
  if (result.exitCode !== 0) {
1402
- return trackResponse("ctx_fetch_and_index", {
1403
- content: [
1404
- {
1405
- type: "text",
1406
- text: `Failed to fetch ${url}: ${result.stderr || result.stdout}`,
1407
- },
1408
- ],
1409
- isError: true,
1410
- });
1756
+ return { kind: "fetch_error", url, error: result.stderr || result.stdout || "unknown error", reason: "exit" };
1411
1757
  }
1412
- // Parse content-type marker from stdout (content is in the temp file)
1413
- const store = getStore();
1414
1758
  const header = (result.stdout || "").trim();
1415
- // Read full content from temp file
1416
1759
  let markdown;
1417
1760
  try {
1418
1761
  markdown = readFileSync(outputPath, "utf-8").trim();
1419
1762
  }
1420
1763
  catch {
1421
- return trackResponse("ctx_fetch_and_index", {
1422
- content: [
1423
- {
1424
- type: "text",
1425
- text: `Fetched ${url} but could not read subprocess output`,
1426
- },
1427
- ],
1428
- isError: true,
1429
- });
1764
+ return { kind: "fetch_error", url, error: "could not read subprocess output", reason: "read" };
1430
1765
  }
1431
1766
  if (markdown.length === 0) {
1767
+ return { kind: "fetch_error", url, error: "empty content", reason: "empty" };
1768
+ }
1769
+ return { kind: "fetched", url, source, markdown, header };
1770
+ }
1771
+ catch (err) {
1772
+ return {
1773
+ kind: "fetch_error",
1774
+ url,
1775
+ error: err instanceof Error ? err.message : String(err),
1776
+ reason: "throw",
1777
+ };
1778
+ }
1779
+ finally {
1780
+ try {
1781
+ rmSync(outputPath);
1782
+ }
1783
+ catch { /* already gone */ }
1784
+ }
1785
+ }
1786
+ /**
1787
+ * Serial-only indexing step — single FTS5 write per call. Caller loops over
1788
+ * fetched results and calls this one-at-a-time to avoid SQLite WAL contention
1789
+ * (PRD finding E).
1790
+ */
1791
+ function indexFetched(f) {
1792
+ const store = getStore();
1793
+ // Storage label composed via composeFetchCacheKey so two URLs sharing a
1794
+ // `source` label do not overwrite each other (commit 1f1243e). ctx_search()
1795
+ // still finds both via LIKE-mode source filter on the `source` substring.
1796
+ const storageLabel = composeFetchCacheKey(f.source, f.url);
1797
+ let indexed;
1798
+ if (f.header === "__CM_CT__:json") {
1799
+ indexed = store.indexJSON(f.markdown, storageLabel);
1800
+ }
1801
+ else if (f.header === "__CM_CT__:text") {
1802
+ indexed = store.indexPlainText(f.markdown, storageLabel);
1803
+ }
1804
+ else {
1805
+ indexed = store.index({ content: f.markdown, source: storageLabel });
1806
+ }
1807
+ // Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
1808
+ trackIndexed(Buffer.byteLength(f.markdown));
1809
+ const preview = f.markdown.length > FETCH_PREVIEW_LIMIT
1810
+ ? f.markdown.slice(0, FETCH_PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
1811
+ : f.markdown;
1812
+ return {
1813
+ label: indexed.label,
1814
+ totalChunks: indexed.totalChunks,
1815
+ totalBytes: Buffer.byteLength(f.markdown),
1816
+ preview,
1817
+ };
1818
+ }
1819
+ server.registerTool("ctx_fetch_and_index", {
1820
+ title: "Fetch & Index URL(s)",
1821
+ description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
1822
+ "and returns a ~3KB preview. Full content stays in sandbox — use ctx_search() for deeper lookups.\n\n" +
1823
+ "Better than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context.\n\n" +
1824
+ "Content-type aware: HTML is converted to markdown, JSON is chunked by key paths, plain text is indexed directly.\n\n" +
1825
+ "PARALLELIZE I/O: For multi-URL research (library evaluation, migration scans, doc comparisons), pass `requests: [{url, source}, ...]` with `concurrency: 4-8` — speeds up by 3-5x on real workloads.\n" +
1826
+ " ✅ Use concurrency: 4-8 for: library docs sweep, multi-changelog scan, competitive pricing pages, multi-region docs, GitHub raw file pulls.\n" +
1827
+ " ❌ Single URL → use the legacy {url, source} shape (concurrency irrelevant).\n" +
1828
+ " Example: requests: [{url: 'https://react.dev/...', source: 'react'}, {url: 'https://vuejs.org/...', source: 'vue'}], concurrency: 5.\n" +
1829
+ " Indexing is serial regardless of concurrency — fetches race, FTS5 writes don't (avoids SQLite WAL contention).\n\n" +
1830
+ "When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
1831
+ inputSchema: z.object({
1832
+ url: z.string().optional().describe("Single URL to fetch and index (legacy single-shape)"),
1833
+ source: z
1834
+ .string()
1835
+ .optional()
1836
+ .describe("Label for the indexed content when using single `url` (e.g., 'React useEffect docs', 'Supabase Auth API'). For batch, put source in each requests entry."),
1837
+ requests: z
1838
+ .array(z.object({
1839
+ url: z.string().describe("URL to fetch"),
1840
+ source: z.string().optional().describe("Label for this URL's indexed content"),
1841
+ }))
1842
+ .min(1)
1843
+ .optional()
1844
+ .describe("Batch shape: array of {url, source?} entries. Use with concurrency>1 for parallel fetch. " +
1845
+ "Each request indexed under its own source label. Output preserves input order."),
1846
+ concurrency: z
1847
+ .coerce.number()
1848
+ .int()
1849
+ .min(1)
1850
+ .max(8)
1851
+ .optional()
1852
+ .default(1)
1853
+ .describe("Max URLs to fetch in parallel (1-8, default: 1). " +
1854
+ "Use 4-8 for I/O-bound multi-URL batches (library docs, changelogs, pricing pages). " +
1855
+ "Capped by os.cpus().length on small machines (response notes when capped). " +
1856
+ "Indexing is always serial regardless — only fetches race."),
1857
+ force: z
1858
+ .boolean()
1859
+ .optional()
1860
+ .describe("Skip cache and re-fetch even if content was recently indexed"),
1861
+ }),
1862
+ }, async ({ url, source, requests, concurrency, force }) => {
1863
+ // Normalize input: legacy {url} or new {requests: [...]}.
1864
+ // requests wins when both are provided (explicit batch intent).
1865
+ const batch = requests
1866
+ ? requests
1867
+ : url
1868
+ ? [{ url, source }]
1869
+ : [];
1870
+ if (batch.length === 0) {
1871
+ return trackResponse("ctx_fetch_and_index", {
1872
+ content: [{
1873
+ type: "text",
1874
+ text: "ctx_fetch_and_index requires either `url` (single) or `requests: [{url, source?}, ...]` (batch).",
1875
+ }],
1876
+ isError: true,
1877
+ });
1878
+ }
1879
+ const isLegacySingle = !requests && batch.length === 1;
1880
+ const requestedConcurrency = concurrency ?? 1;
1881
+ // Parallel fetch via shared runPool primitive. capByCpuCount only for batch
1882
+ // — single-URL doesn't need the cap (only one job, executor is one subprocess).
1883
+ const jobs = batch.map((req) => ({
1884
+ run: () => fetchOneUrl(req.url, req.source, force),
1885
+ }));
1886
+ const { settled, effectiveConcurrency, capped } = await runPool(jobs, {
1887
+ concurrency: requestedConcurrency,
1888
+ capByCpuCount: !isLegacySingle && requestedConcurrency > 1,
1889
+ });
1890
+ const finalized = [];
1891
+ for (let i = 0; i < settled.length; i++) {
1892
+ const r = settled[i];
1893
+ if (r.status === "rejected") {
1894
+ const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
1895
+ finalized.push({ kind: "job_error", url: batch[i].url, error: message });
1896
+ continue;
1897
+ }
1898
+ const v = r.value;
1899
+ if (v.kind === "cached") {
1900
+ sessionStats.cacheHits++;
1901
+ sessionStats.cacheBytesSaved += v.estimatedBytes;
1902
+ finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr });
1903
+ }
1904
+ else if (v.kind === "fetch_error") {
1905
+ finalized.push({ kind: "fetch_error", url: v.url, error: v.error, reason: v.reason });
1906
+ }
1907
+ else {
1908
+ // Serial FTS5 write here — no parallel store.index calls.
1909
+ finalized.push({ kind: "fetched", indexed: indexFetched(v) });
1910
+ }
1911
+ }
1912
+ // Backward-compat single-URL response shape — preserve the EXACT original wording.
1913
+ if (isLegacySingle) {
1914
+ const r = finalized[0];
1915
+ if (r.kind === "cached") {
1432
1916
  return trackResponse("ctx_fetch_and_index", {
1433
- content: [
1434
- {
1917
+ content: [{
1435
1918
  type: "text",
1436
- text: `Fetched ${url} but got empty content`,
1437
- },
1438
- ],
1439
- isError: true,
1919
+ text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL: 24h).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${r.label}")`,
1920
+ }],
1440
1921
  });
1441
1922
  }
1442
- trackIndexed(Buffer.byteLength(markdown));
1443
- // Route to the appropriate indexing strategy based on Content-Type
1444
- let indexed;
1445
- if (header === "__CM_CT__:json") {
1446
- indexed = store.indexJSON(markdown, source ?? url);
1923
+ if (r.kind === "fetched") {
1924
+ const totalKB = (r.indexed.totalBytes / 1024).toFixed(1);
1925
+ const text = [
1926
+ `Fetched and indexed **${r.indexed.totalChunks} sections** (${totalKB}KB) from: ${r.indexed.label}`,
1927
+ `Full content indexed in sandbox — use ctx_search(queries: [...], source: "${r.indexed.label}") for specific lookups.`,
1928
+ "",
1929
+ "---",
1930
+ "",
1931
+ r.indexed.preview,
1932
+ ].join("\n");
1933
+ return trackResponse("ctx_fetch_and_index", {
1934
+ content: [{ type: "text", text }],
1935
+ });
1447
1936
  }
1448
- else if (header === "__CM_CT__:text") {
1449
- indexed = store.indexPlainText(markdown, source ?? url);
1937
+ // fetch_error preserve original error wording per reason
1938
+ if (r.kind === "fetch_error") {
1939
+ const text = r.reason === "empty" ? `Fetched ${r.url} but got empty content`
1940
+ : r.reason === "read" ? `Fetched ${r.url} but could not read subprocess output`
1941
+ : r.reason === "exit" ? `Failed to fetch ${r.url}: ${r.error}`
1942
+ : /* throw */ `Fetch error: ${r.error}`;
1943
+ return trackResponse("ctx_fetch_and_index", {
1944
+ content: [{ type: "text", text }],
1945
+ isError: true,
1946
+ });
1450
1947
  }
1451
- else {
1452
- // HTML (default) — content is already converted to markdown
1453
- indexed = store.index({ content: markdown, source: source ?? url });
1454
- }
1455
- // Build preview — first ~3KB of markdown for immediate use
1456
- const PREVIEW_LIMIT = 3072;
1457
- const preview = markdown.length > PREVIEW_LIMIT
1458
- ? markdown.slice(0, PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
1459
- : markdown;
1460
- const totalKB = (Buffer.byteLength(markdown) / 1024).toFixed(1);
1461
- const text = [
1462
- `Fetched and indexed **${indexed.totalChunks} sections** (${totalKB}KB) from: ${indexed.label}`,
1463
- `Full content indexed in sandbox — use ctx_search(queries: [...], source: "${indexed.label}") for specific lookups.`,
1464
- "",
1465
- "---",
1466
- "",
1467
- preview,
1468
- ].join("\n");
1469
- return trackResponse("ctx_fetch_and_index", {
1470
- content: [{ type: "text", text }],
1471
- });
1472
- }
1473
- catch (err) {
1474
- const message = err instanceof Error ? err.message : String(err);
1948
+ // job_error
1475
1949
  return trackResponse("ctx_fetch_and_index", {
1476
- content: [
1477
- { type: "text", text: `Fetch error: ${message}` },
1478
- ],
1950
+ content: [{ type: "text", text: `Fetch error: ${r.error}` }],
1479
1951
  isError: true,
1480
1952
  });
1481
1953
  }
1482
- finally {
1483
- // Clean up temp file
1484
- try {
1485
- rmSync(outputPath);
1954
+ // Batch response — aggregated summary; isError only when EVERY URL failed.
1955
+ // Per-URL preview capped tightly so a 8-URL batch doesn't undo the
1956
+ // context-savings the tool exists to deliver (PRD review finding G1).
1957
+ const FETCH_BATCH_PREVIEW_LIMIT = 384; // ~3KB total for 8-URL batches
1958
+ const lines = [];
1959
+ let totalSections = 0;
1960
+ let totalBytes = 0;
1961
+ let cachedCount = 0;
1962
+ let fetchedCount = 0;
1963
+ let errorCount = 0;
1964
+ const snippets = [];
1965
+ for (const r of finalized) {
1966
+ if (r.kind === "cached") {
1967
+ cachedCount++;
1968
+ lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr})`);
1969
+ }
1970
+ else if (r.kind === "fetched") {
1971
+ fetchedCount++;
1972
+ totalSections += r.indexed.totalChunks;
1973
+ totalBytes += r.indexed.totalBytes;
1974
+ const kb = (r.indexed.totalBytes / 1024).toFixed(1);
1975
+ lines.push(`- [new] ${r.indexed.label} — ${r.indexed.totalChunks} sections (${kb}KB)`);
1976
+ const snippet = r.indexed.preview.length > FETCH_BATCH_PREVIEW_LIMIT
1977
+ ? r.indexed.preview.slice(0, FETCH_BATCH_PREVIEW_LIMIT).trimEnd() + "…"
1978
+ : r.indexed.preview;
1979
+ snippets.push(`### ${r.indexed.label}\n\n${snippet}`);
1486
1980
  }
1487
- catch { /* already gone */ }
1488
- }
1981
+ else {
1982
+ errorCount++;
1983
+ lines.push(`- [err] ${r.url}: ${r.error}`);
1984
+ }
1985
+ }
1986
+ const totalKB = (totalBytes / 1024).toFixed(1);
1987
+ const cappedNote = capped
1988
+ ? ` cap=${effectiveConcurrency}/${cpus().length}cpu`
1989
+ : "";
1990
+ // Caveman style — terse status line: counts + sections + size.
1991
+ // Singular forms used at count=1 to avoid grammar drift ("1 errors" → "1 error").
1992
+ const fmt = (n, sing, plur) => `${n} ${n === 1 ? sing : plur}`;
1993
+ const headerLine = `fetched ${batch.length} c=${effectiveConcurrency}${cappedNote}. ` +
1994
+ `ok=${fetchedCount} cache=${cachedCount} err=${errorCount}. ` +
1995
+ `${fmt(totalSections, "section", "sections")} ${totalKB}KB.`;
1996
+ const text = [
1997
+ headerLine,
1998
+ "",
1999
+ ...lines,
2000
+ "",
2001
+ `ctx_search(queries: [...], source: "<label>") for full content.`,
2002
+ ...(snippets.length > 0 ? ["", "---", "", ...snippets] : []),
2003
+ ].join("\n");
2004
+ return trackResponse("ctx_fetch_and_index", {
2005
+ content: [{ type: "text", text }],
2006
+ isError: errorCount === batch.length, // only mark error if every URL failed
2007
+ });
1489
2008
  });
1490
2009
  // ─────────────────────────────────────────────────────────
1491
2010
  // Tool: batch_execute
@@ -1497,7 +2016,12 @@ server.registerTool("ctx_batch_execute", {
1497
2016
  "THIS IS THE PRIMARY TOOL. Use this instead of multiple ctx_execute() calls.\n\n" +
1498
2017
  "One ctx_batch_execute call replaces 30+ ctx_execute calls + 10+ ctx_search calls.\n" +
1499
2018
  "Provide all commands to run and all queries to search — everything happens in one round trip.\n\n" +
1500
- "THINK IN CODE: When commands produce data you need to analyze, add processing commands that filter and summarize. Don't pull raw output into context — let the sandbox do the work.\n\n" +
2019
+ "PARALLELIZE I/O: For I/O-bound batches (network calls, slow API queries, multi-URL fetches), ALWAYS pass concurrency: 4-8 speeds up by 3-5x on real workloads.\n" +
2020
+ " ✅ Use concurrency: 4-8 for: gh API calls, curl/web fetches, multi-region cloud queries, multi-repo git reads, dig/DNS, docker inspect.\n" +
2021
+ " ❌ Keep concurrency: 1 for: npm test, build, lint, image processing (CPU-bound), or commands sharing state (ports, lock files, same-repo writes).\n" +
2022
+ " Example: [gh issue view 1, gh issue view 2, gh issue view 3] → concurrency: 3.\n" +
2023
+ " Speedup depends on workload — applies to I/O wait, not CPU work.\n\n" +
2024
+ "THINK IN CODE — NON-NEGOTIABLE: When commands produce data you need to analyze, count, filter, compare, or transform — add a processing command that runs JavaScript and console.log() ONLY the answer. NEVER pull raw output into context to reason over. Concurrency parallelizes the FETCH; THINK IN CODE owns the PROCESSING. One programmed analysis replaces ten read-and-reason rounds. Pure JavaScript, Node.js built-ins (fs, path, child_process), try/catch, null-safe.\n\n" +
1501
2025
  "When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
1502
2026
  inputSchema: z.object({
1503
2027
  commands: z.preprocess(coerceCommandsArray, z
@@ -1510,7 +2034,8 @@ server.registerTool("ctx_batch_execute", {
1510
2034
  .describe("Shell command to execute"),
1511
2035
  }))
1512
2036
  .min(1)
1513
- .describe("Commands to execute as a batch. Each runs sequentially, output is labeled with the section header.")),
2037
+ .describe("Commands to execute as a batch. Output is labeled with the section header. " +
2038
+ "Default order is sequential; pass concurrency>1 to run in parallel (output stays in input order).")),
1514
2039
  queries: z.preprocess(coerceJsonArray, z
1515
2040
  .array(z.string())
1516
2041
  .min(1)
@@ -1520,10 +2045,21 @@ server.registerTool("ctx_batch_execute", {
1520
2045
  timeout: z
1521
2046
  .coerce.number()
1522
2047
  .optional()
1523
- .default(60000)
1524
- .describe("Max execution time in ms (default: 60s)"),
2048
+ .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs. With concurrency=1, the value (when set) is a shared budget across commands; with concurrency>1, it is applied per-command."),
2049
+ concurrency: z
2050
+ .coerce.number()
2051
+ .int()
2052
+ .min(1)
2053
+ .max(8)
2054
+ .optional()
2055
+ .default(1)
2056
+ .describe("Max commands to run in parallel (1-8, default: 1). " +
2057
+ "Use 4-8 for I/O-bound batches (network, gh, curl, multi-repo git reads). " +
2058
+ "Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
2059
+ ">1 switches to per-command timeouts (no shared budget) and " +
2060
+ "individual `(timed out)` blocks instead of cascading skip."),
1525
2061
  }),
1526
- }, async ({ commands, queries, timeout }) => {
2062
+ }, async ({ commands, queries, timeout, concurrency }) => {
1527
2063
  // Security: check each command against deny patterns
1528
2064
  for (const cmd of commands) {
1529
2065
  const denied = checkDenyPolicy(cmd.command, "batch_execute");
@@ -1531,51 +2067,18 @@ server.registerTool("ctx_batch_execute", {
1531
2067
  return denied;
1532
2068
  }
1533
2069
  try {
1534
- // Execute each command individually so every command gets its own
1535
- // output capture. Full stdout is preserved and indexed into FTS5.
1536
- // (Issue #61, #197)
1537
- const perCommandOutputs = [];
1538
- const startTime = Date.now();
1539
- let timedOut = false;
1540
2070
  // Inject NODE_OPTIONS for FS read tracking in spawned Node processes.
1541
2071
  // The executor denies NODE_OPTIONS in its env (security), so we set it
1542
2072
  // as an inline shell prefix. This only affects child `node` invocations.
1543
2073
  const nodeOptsPrefix = `NODE_OPTIONS="--require ${CM_FS_PRELOAD}" `;
1544
- for (const cmd of commands) {
1545
- const elapsed = Date.now() - startTime;
1546
- const remaining = timeout - elapsed;
1547
- if (remaining <= 0) {
1548
- perCommandOutputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`);
1549
- timedOut = true;
1550
- continue;
1551
- }
1552
- const result = await executor.execute({
1553
- language: "shell",
1554
- code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
1555
- timeout: remaining,
1556
- });
1557
- let output = result.stdout || "(no output)";
1558
- // Parse and strip __CM_FS__ markers emitted by the preload script.
1559
- // Because 2>&1 merges stderr into stdout, markers appear in output.
1560
- const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
1561
- let cmdFsBytes = 0;
1562
- for (const m of fsMatches)
1563
- cmdFsBytes += parseInt(m[1]);
1564
- if (cmdFsBytes > 0) {
1565
- sessionStats.bytesSandboxed += cmdFsBytes;
1566
- output = output.replace(/__CM_FS__:\d+\n?/g, "");
1567
- }
1568
- perCommandOutputs.push(`# ${cmd.label}\n\n${output}\n`);
1569
- if (result.timedOut) {
1570
- timedOut = true;
1571
- // Mark remaining commands as skipped
1572
- const idx = commands.indexOf(cmd);
1573
- for (let i = idx + 1; i < commands.length; i++) {
1574
- perCommandOutputs.push(`# ${commands[i].label}\n\n(skipped — batch timeout exceeded)\n`);
1575
- }
1576
- break;
1577
- }
1578
- }
2074
+ // Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
2075
+ // Concurrency>1 switches to a worker pool with per-command timeouts.
2076
+ const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
2077
+ timeout,
2078
+ concurrency,
2079
+ nodeOptsPrefix,
2080
+ onFsBytes: (bytes) => { sessionStats.bytesSandboxed += bytes; },
2081
+ }, executor);
1579
2082
  const stdout = perCommandOutputs.join("\n");
1580
2083
  const totalBytes = Buffer.byteLength(stdout);
1581
2084
  const totalLines = stdout.split("\n").length;
@@ -1678,24 +2181,37 @@ server.registerTool("ctx_stats", {
1678
2181
  try {
1679
2182
  const engine = new AnalyticsEngine(sdb);
1680
2183
  const report = engine.queryAll(sessionStats);
1681
- text = formatReport(report, VERSION, _latestVersion);
2184
+ // MCP usage is read-only and cheap; only available when DB exists.
2185
+ const mcpUsage = engine.getMcpToolUsage();
2186
+ // Lifetime stats span every project's SessionDB + auto-memory dir
2187
+ // (Bugs #3/#4); failures are absorbed inside getLifetimeStats so a
2188
+ // corrupt sidecar can never break ctx_stats.
2189
+ const lifetime = getLifetimeStats();
2190
+ text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage });
1682
2191
  }
1683
2192
  finally {
1684
2193
  sdb.close();
1685
2194
  }
1686
2195
  }
1687
2196
  else {
1688
- // No session DB — build a minimal report from runtime stats only
2197
+ // No session DB — build a minimal report from runtime stats only.
2198
+ // Lifetime still meaningful (other projects, auto-memory) so include it.
1689
2199
  const engine = new AnalyticsEngine(createMinimalDb());
1690
2200
  const report = engine.queryAll(sessionStats);
1691
- text = formatReport(report, VERSION, _latestVersion);
2201
+ const lifetime = getLifetimeStats();
2202
+ text = formatReport(report, VERSION, _latestVersion, { lifetime });
1692
2203
  }
1693
2204
  }
1694
2205
  catch {
1695
2206
  // Session DB not available or incompatible — build minimal report from runtime stats
1696
2207
  const engine = new AnalyticsEngine(createMinimalDb());
1697
2208
  const report = engine.queryAll(sessionStats);
1698
- text = formatReport(report, VERSION, _latestVersion);
2209
+ let lifetime;
2210
+ try {
2211
+ lifetime = getLifetimeStats();
2212
+ }
2213
+ catch { /* never block ctx_stats */ }
2214
+ text = formatReport(report, VERSION, _latestVersion, lifetime ? { lifetime } : undefined);
1699
2215
  }
1700
2216
  return trackResponse("ctx_stats", {
1701
2217
  content: [{ type: "text", text }],
@@ -1705,22 +2221,30 @@ server.registerTool("ctx_stats", {
1705
2221
  server.registerTool("ctx_doctor", {
1706
2222
  title: "Run Diagnostics",
1707
2223
  description: "Diagnose context-mode installation. Runs all checks server-side and " +
1708
- "returns results as a markdown checklist. No CLI execution needed.",
2224
+ "returns a plain-text status report with [OK]/[FAIL]/[WARN] prefixes " +
2225
+ "(renderer-safe across MCP clients). No CLI execution needed.",
1709
2226
  inputSchema: z.object({}),
1710
2227
  }, async () => {
1711
- const lines = ["## context-mode doctor", ""];
2228
+ // Renderer-safe output (Mickey #3 Z.ai GLM 4.7 ReferenceError):
2229
+ // Z.ai's MCP renderer mounts a custom React component for GitHub-flavored
2230
+ // markdown task-list syntax (`- [x]` / `- [ ]` / `- [-]`) that depends on
2231
+ // a missing `client` context, throwing `ReferenceError: client is not
2232
+ // defined`. We avoid both task-list syntax AND `## ` h2 headings to stay
2233
+ // safe across all MCP renderers — using plain-text status prefixes
2234
+ // (`[OK]` / `[FAIL]` / `[WARN]`) instead.
2235
+ const lines = ["context-mode doctor", ""];
1712
2236
  // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
1713
2237
  const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
1714
2238
  // Runtimes
1715
2239
  const total = 11;
1716
2240
  const pct = ((available.length / total) * 100).toFixed(0);
1717
- lines.push(`- [x] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
2241
+ lines.push(`[OK] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
1718
2242
  // Performance
1719
2243
  if (hasBunRuntime()) {
1720
- lines.push("- [x] Performance: FAST (Bun)");
2244
+ lines.push("[OK] Performance: FAST (Bun)");
1721
2245
  }
1722
2246
  else {
1723
- lines.push("- [-] Performance: NORMAL — install Bun for 3-5x speed boost");
2247
+ lines.push("[WARN] Performance: NORMAL — install Bun for 3-5x speed boost");
1724
2248
  }
1725
2249
  // Server test — cleanup executor to prevent resource leaks (#247)
1726
2250
  {
@@ -1728,15 +2252,15 @@ server.registerTool("ctx_doctor", {
1728
2252
  try {
1729
2253
  const result = await testExecutor.execute({ language: "javascript", code: 'console.log("ok");', timeout: 5000 });
1730
2254
  if (result.exitCode === 0 && result.stdout.trim() === "ok") {
1731
- lines.push("- [x] Server test: PASS");
2255
+ lines.push("[OK] Server test: PASS");
1732
2256
  }
1733
2257
  else {
1734
2258
  const detail = result.stderr?.trim() ? ` (${result.stderr.trim().slice(0, 200)})` : "";
1735
- lines.push(`- [ ] Server test: FAIL — exit ${result.exitCode}${detail}`);
2259
+ lines.push(`[FAIL] Server test: FAIL — exit ${result.exitCode}${detail}`);
1736
2260
  }
1737
2261
  }
1738
2262
  catch (err) {
1739
- lines.push(`- [ ] Server test: FAIL — ${err instanceof Error ? err.message : err}`);
2263
+ lines.push(`[FAIL] Server test: FAIL — ${err instanceof Error ? err.message : err}`);
1740
2264
  }
1741
2265
  finally {
1742
2266
  testExecutor.cleanupBackgrounded();
@@ -1752,14 +2276,14 @@ server.registerTool("ctx_doctor", {
1752
2276
  testDb.exec("INSERT INTO fts_test(content) VALUES ('hello world')");
1753
2277
  const row = testDb.prepare("SELECT * FROM fts_test WHERE fts_test MATCH 'hello'").get();
1754
2278
  if (row && row.content === "hello world") {
1755
- lines.push("- [x] FTS5 / SQLite: PASS — native module works");
2279
+ lines.push("[OK] FTS5 / SQLite: PASS — native module works");
1756
2280
  }
1757
2281
  else {
1758
- lines.push("- [ ] FTS5 / SQLite: FAIL — unexpected result");
2282
+ lines.push("[FAIL] FTS5 / SQLite: FAIL — unexpected result");
1759
2283
  }
1760
2284
  }
1761
2285
  catch (err) {
1762
- lines.push(`- [ ] FTS5 / SQLite: FAIL — ${err instanceof Error ? err.message : err}`);
2286
+ lines.push(`[FAIL] FTS5 / SQLite: FAIL — ${err instanceof Error ? err.message : err}`);
1763
2287
  }
1764
2288
  finally {
1765
2289
  try {
@@ -1771,13 +2295,13 @@ server.registerTool("ctx_doctor", {
1771
2295
  // Hook script
1772
2296
  const hookPath = resolve(pluginRoot, "hooks", "pretooluse.mjs");
1773
2297
  if (existsSync(hookPath)) {
1774
- lines.push(`- [x] Hook script: PASS — ${hookPath}`);
2298
+ lines.push(`[OK] Hook script: PASS — ${hookPath}`);
1775
2299
  }
1776
2300
  else {
1777
- lines.push(`- [ ] Hook script: FAIL — not found at ${hookPath}`);
2301
+ lines.push(`[FAIL] Hook script: FAIL — not found at ${hookPath}`);
1778
2302
  }
1779
2303
  // Version
1780
- lines.push(`- [x] Version: v${VERSION}`);
2304
+ lines.push(`[OK] Version: v${VERSION}`);
1781
2305
  return trackResponse("ctx_doctor", {
1782
2306
  content: [{ type: "text", text: lines.join("\n") }],
1783
2307
  });
@@ -1985,6 +2509,13 @@ server.registerTool("ctx_purge", {
1985
2509
  sessionStats.cacheBytesSaved = 0;
1986
2510
  sessionStats.sessionStart = Date.now();
1987
2511
  deleted.push("session stats");
2512
+ // Also drop the persisted stats file so external readers see a fresh state
2513
+ try {
2514
+ const statsFile = getStatsFilePath();
2515
+ if (existsSync(statsFile))
2516
+ unlinkSync(statsFile);
2517
+ }
2518
+ catch { /* best effort */ }
1988
2519
  return trackResponse("ctx_purge", {
1989
2520
  content: [{
1990
2521
  type: "text",
@@ -2001,14 +2532,22 @@ server.registerTool("ctx_insight", {
2001
2532
  "First run installs dependencies (~30s). Subsequent runs open instantly.",
2002
2533
  inputSchema: z.object({
2003
2534
  port: z.coerce.number().optional().describe("Port to serve on (default: 4747)"),
2535
+ sessionDir: z.string().optional().describe("Override INSIGHT_SESSION_DIR: directory containing context-mode session .db files"),
2536
+ contentDir: z.string().optional().describe("Override INSIGHT_CONTENT_DIR: directory containing context-mode content/index .db files"),
2537
+ insightSessionDir: z.string().optional().describe("Alias for sessionDir / INSIGHT_SESSION_DIR"),
2538
+ insightContentDir: z.string().optional().describe("Alias for contentDir / INSIGHT_CONTENT_DIR"),
2004
2539
  }),
2005
- }, async ({ port: userPort }) => {
2540
+ }, async ({ port: userPort, sessionDir, contentDir, insightSessionDir, insightContentDir }) => {
2006
2541
  const port = userPort || 4747;
2542
+ const explicitSessionDir = sessionDir || insightSessionDir;
2543
+ const explicitContentDir = contentDir || insightContentDir;
2007
2544
  // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
2008
2545
  const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
2009
2546
  const insightSource = resolve(pluginRoot, "insight");
2010
- // Use adapter-aware path: derive from sessions dir (works across all 12 adapters)
2011
- const sessDir = getSessionDir();
2547
+ // Use adapter-aware path by default, but allow MCP callers to pass explicit
2548
+ // Insight data dirs for hosts whose adapter/default detection is unavailable.
2549
+ const sessDir = explicitSessionDir ? resolve(explicitSessionDir) : getSessionDir();
2550
+ const insightContentDirResolved = explicitContentDir ? resolve(explicitContentDir) : join(dirname(sessDir), "content");
2012
2551
  const cacheDir = join(dirname(sessDir), "insight-cache");
2013
2552
  // Verify source exists
2014
2553
  if (!existsSync(join(insightSource, "server.mjs"))) {
@@ -2133,8 +2672,8 @@ server.registerTool("ctx_insight", {
2133
2672
  env: {
2134
2673
  ...process.env,
2135
2674
  PORT: String(port),
2136
- INSIGHT_SESSION_DIR: getSessionDir(),
2137
- INSIGHT_CONTENT_DIR: join(dirname(getSessionDir()), "content"),
2675
+ INSIGHT_SESSION_DIR: sessDir,
2676
+ INSIGHT_CONTENT_DIR: insightContentDirResolved,
2138
2677
  INSIGHT_PARENT_PID: String(process.pid),
2139
2678
  },
2140
2679
  detached: true,
@@ -2231,6 +2770,15 @@ async function main() {
2231
2770
  }
2232
2771
  };
2233
2772
  const gracefulShutdown = async () => {
2773
+ // Final stats flush — bypass throttle so the last 0-500ms of
2774
+ // bytes_indexed / bytes_returned aren't silently lost on SIGTERM/SIGINT
2775
+ // (PR #401 grill-me review B1: persistStats early-returns inside throttle
2776
+ // window; gracefulShutdown previously did NOT bypass).
2777
+ try {
2778
+ _lastStatsPersist = 0;
2779
+ persistStats();
2780
+ }
2781
+ catch { /* best effort — never block shutdown */ }
2234
2782
  shutdown();
2235
2783
  process.exit(0);
2236
2784
  };