context-mode 0.9.21 → 1.0.0

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 (102) hide show
  1. package/.claude-plugin/hooks/hooks.json +46 -4
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/.claude-plugin/plugin.json +4 -4
  4. package/README.md +377 -191
  5. package/build/adapters/claude-code/config.d.ts +8 -0
  6. package/build/adapters/claude-code/config.js +8 -0
  7. package/build/adapters/claude-code/hooks.d.ts +53 -0
  8. package/build/adapters/claude-code/hooks.js +88 -0
  9. package/build/adapters/claude-code/index.d.ts +50 -0
  10. package/build/adapters/claude-code/index.js +523 -0
  11. package/build/adapters/codex/config.d.ts +8 -0
  12. package/build/adapters/codex/config.js +8 -0
  13. package/build/adapters/codex/hooks.d.ts +21 -0
  14. package/build/adapters/codex/hooks.js +27 -0
  15. package/build/adapters/codex/index.d.ts +44 -0
  16. package/build/adapters/codex/index.js +223 -0
  17. package/build/adapters/detect.d.ts +26 -0
  18. package/build/adapters/detect.js +131 -0
  19. package/build/adapters/gemini-cli/config.d.ts +8 -0
  20. package/build/adapters/gemini-cli/config.js +8 -0
  21. package/build/adapters/gemini-cli/hooks.d.ts +44 -0
  22. package/build/adapters/gemini-cli/hooks.js +64 -0
  23. package/build/adapters/gemini-cli/index.d.ts +57 -0
  24. package/build/adapters/gemini-cli/index.js +468 -0
  25. package/build/adapters/opencode/config.d.ts +8 -0
  26. package/build/adapters/opencode/config.js +8 -0
  27. package/build/adapters/opencode/hooks.d.ts +38 -0
  28. package/build/adapters/opencode/hooks.js +50 -0
  29. package/build/adapters/opencode/index.d.ts +52 -0
  30. package/build/adapters/opencode/index.js +386 -0
  31. package/build/adapters/types.d.ts +218 -0
  32. package/build/adapters/types.js +13 -0
  33. package/build/adapters/vscode-copilot/config.d.ts +8 -0
  34. package/build/adapters/vscode-copilot/config.js +8 -0
  35. package/build/adapters/vscode-copilot/hooks.d.ts +49 -0
  36. package/build/adapters/vscode-copilot/hooks.js +76 -0
  37. package/build/adapters/vscode-copilot/index.d.ts +58 -0
  38. package/build/adapters/vscode-copilot/index.js +512 -0
  39. package/build/cli.d.ts +9 -6
  40. package/build/cli.js +133 -423
  41. package/build/db-base.d.ts +84 -0
  42. package/build/db-base.js +128 -0
  43. package/build/executor.d.ts +6 -7
  44. package/build/executor.js +111 -51
  45. package/build/opencode-plugin.d.ts +37 -0
  46. package/build/opencode-plugin.js +118 -0
  47. package/build/runtime.js +1 -1
  48. package/build/server.js +436 -117
  49. package/build/session/db.d.ts +110 -0
  50. package/build/session/db.js +285 -0
  51. package/build/session/extract.d.ts +51 -0
  52. package/build/session/extract.js +407 -0
  53. package/build/session/snapshot.d.ts +70 -0
  54. package/build/session/snapshot.js +309 -0
  55. package/build/store.d.ts +4 -22
  56. package/build/store.js +67 -55
  57. package/build/truncate.d.ts +59 -0
  58. package/build/truncate.js +157 -0
  59. package/build/types.d.ts +101 -0
  60. package/build/types.js +20 -0
  61. package/configs/claude-code/CLAUDE.md +62 -0
  62. package/configs/codex/AGENTS.md +58 -0
  63. package/configs/codex/config.toml +5 -0
  64. package/configs/gemini-cli/GEMINI.md +58 -0
  65. package/configs/gemini-cli/mcp.json +7 -0
  66. package/configs/gemini-cli/settings.json +49 -0
  67. package/configs/opencode/AGENTS.md +58 -0
  68. package/configs/opencode/opencode.json +10 -0
  69. package/configs/vscode-copilot/copilot-instructions.md +58 -0
  70. package/configs/vscode-copilot/hooks.json +16 -0
  71. package/configs/vscode-copilot/mcp.json +8 -0
  72. package/hooks/core/formatters.mjs +86 -0
  73. package/hooks/core/routing.mjs +262 -0
  74. package/hooks/core/stdin.mjs +19 -0
  75. package/hooks/formatters/claude-code.mjs +57 -0
  76. package/hooks/formatters/gemini-cli.mjs +55 -0
  77. package/hooks/formatters/vscode-copilot.mjs +55 -0
  78. package/hooks/gemini-cli/aftertool.mjs +58 -0
  79. package/hooks/gemini-cli/beforetool.mjs +25 -0
  80. package/hooks/gemini-cli/precompress.mjs +51 -0
  81. package/hooks/gemini-cli/sessionstart.mjs +117 -0
  82. package/hooks/hooks.json +46 -4
  83. package/hooks/posttooluse.mjs +53 -0
  84. package/hooks/precompact.mjs +55 -0
  85. package/hooks/pretooluse.mjs +23 -266
  86. package/hooks/routing-block.mjs +19 -6
  87. package/hooks/session-directive.mjs +353 -0
  88. package/hooks/session-helpers.mjs +112 -0
  89. package/hooks/sessionstart.mjs +123 -16
  90. package/hooks/userpromptsubmit.mjs +58 -0
  91. package/hooks/vscode-copilot/posttooluse.mjs +58 -0
  92. package/hooks/vscode-copilot/precompact.mjs +51 -0
  93. package/hooks/vscode-copilot/pretooluse.mjs +25 -0
  94. package/hooks/vscode-copilot/sessionstart.mjs +115 -0
  95. package/package.json +20 -17
  96. package/skills/context-mode/SKILL.md +49 -49
  97. package/skills/{doctor → ctx-doctor}/SKILL.md +3 -3
  98. package/skills/{stats → ctx-stats}/SKILL.md +3 -3
  99. package/skills/{upgrade → ctx-upgrade}/SKILL.md +3 -3
  100. package/start.mjs +47 -0
  101. package/hooks/pretooluse.sh +0 -147
  102. package/server.bundle.mjs +0 -341
package/build/server.js CHANGED
@@ -2,12 +2,24 @@
2
2
  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
+ import { createHash } from "node:crypto";
6
+ import { existsSync, unlinkSync, readdirSync, readFileSync, rmSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { homedir, tmpdir } from "node:os";
5
10
  import { z } from "zod";
6
11
  import { PolyglotExecutor } from "./executor.js";
7
12
  import { ContentStore, cleanupStaleDBs } from "./store.js";
8
13
  import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, evaluateFilePath, } from "./security.js";
9
14
  import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
10
- const VERSION = "0.9.21";
15
+ const VERSION = "1.0.0";
16
+ // Prevent silent server death from unhandled async errors
17
+ process.on("unhandledRejection", (err) => {
18
+ process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
19
+ });
20
+ process.on("uncaughtException", (err) => {
21
+ process.stderr.write(`[context-mode] uncaughtException: ${err?.message ?? err}\n`);
22
+ });
11
23
  const runtimes = detectRuntimes();
12
24
  const available = getAvailableLanguages(runtimes);
13
25
  const server = new McpServer({
@@ -20,9 +32,35 @@ const executor = new PolyglotExecutor({
20
32
  });
21
33
  // Lazy singleton — no DB overhead unless index/search is used
22
34
  let _store = null;
35
+ /**
36
+ * Auto-index session events files written by SessionStart hook.
37
+ * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
38
+ * CLAUDE_PROJECT_DIR is NOT available to MCP servers — only to hooks —
39
+ * so we glob-scan instead of computing a specific hash.
40
+ * Files are consumed (deleted) after indexing to prevent double-indexing.
41
+ * Called on every getStore() — readdirSync is sub-millisecond when no files match.
42
+ */
43
+ function maybeIndexSessionEvents(store) {
44
+ try {
45
+ const sessionsDir = join(homedir(), ".claude", "context-mode", "sessions");
46
+ if (!existsSync(sessionsDir))
47
+ return;
48
+ const files = readdirSync(sessionsDir).filter(f => f.endsWith("-events.md"));
49
+ for (const file of files) {
50
+ const filePath = join(sessionsDir, file);
51
+ try {
52
+ store.index({ path: filePath, source: "session-events" });
53
+ unlinkSync(filePath);
54
+ }
55
+ catch { /* best-effort per file */ }
56
+ }
57
+ }
58
+ catch { /* best-effort — session continuity never blocks tools */ }
59
+ }
23
60
  function getStore() {
24
61
  if (!_store)
25
62
  _store = new ContentStore();
63
+ maybeIndexSessionEvents(_store);
26
64
  return _store;
27
65
  }
28
66
  // ─────────────────────────────────────────────────────────
@@ -232,7 +270,7 @@ export function extractSnippet(content, query, maxLen = 1500, highlighted) {
232
270
  // ─────────────────────────────────────────────────────────
233
271
  // Tool: execute
234
272
  // ─────────────────────────────────────────────────────────
235
- server.registerTool("execute", {
273
+ server.registerTool("ctx_execute", {
236
274
  title: "Execute Code",
237
275
  description: `MANDATORY: Use for any command where output exceeds 20 lines. Execute code in a sandboxed subprocess. Only stdout enters context — raw data stays in the subprocess.${bunNote} Available: ${langList}.\n\nPREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, pytest), git queries (git log, git diff), data processing, and ANY CLI command that may produce large output. Bash should only be used for file mutations, git writes, and navigation.`,
238
276
  inputSchema: z.object({
@@ -259,6 +297,11 @@ server.registerTool("execute", {
259
297
  .optional()
260
298
  .default(30000)
261
299
  .describe("Max execution time in ms"),
300
+ background: z
301
+ .boolean()
302
+ .optional()
303
+ .default(false)
304
+ .describe("Keep process running after timeout (for servers/daemons). Returns partial output without killing the process. IMPORTANT: Do NOT add setTimeout/self-close timers in background scripts — the process must stay alive until the timeout detaches it. For server+fetch patterns, prefer putting both server and fetch in ONE ctx_execute call instead of using background."),
262
305
  intent: z
263
306
  .string()
264
307
  .optional()
@@ -267,7 +310,7 @@ server.registerTool("execute", {
267
310
  "Use search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." +
268
311
  "\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary."),
269
312
  }),
270
- }, async ({ language, code, timeout, intent }) => {
313
+ }, async ({ language, code, timeout, background, intent }) => {
271
314
  // Security: deny-only firewall
272
315
  if (language === "shell") {
273
316
  const denied = checkDenyPolicy(code, "execute");
@@ -280,22 +323,61 @@ server.registerTool("execute", {
280
323
  return denied;
281
324
  }
282
325
  try {
283
- // For JS/TS: wrap in async IIFE with fetch interceptor to track network bytes
326
+ // For JS/TS: wrap in async IIFE with fetch + http/https interceptors to track network bytes
284
327
  let instrumentedCode = code;
285
328
  if (language === "javascript" || language === "typescript") {
329
+ // Wrap user code in a closure that shadows CJS require with http/https interceptor.
330
+ // globalThis.require does NOT work because CJS require is module-scoped, not global.
331
+ // The closure approach (function(__cm_req){ var require=...; })(require) correctly
332
+ // shadows the CJS require for all code inside, including __cm_main().
286
333
  instrumentedCode = `
287
- let __cm_net=0;const __cm_f=globalThis.fetch;
334
+ let __cm_net=0;
335
+ // Report network bytes on process exit — works with both promise and callback patterns.
336
+ // process.on('exit') fires after all I/O completes, unlike .finally() which fires
337
+ // when __cm_main() resolves (immediately for callback-based http.get without await).
338
+ process.on('exit',()=>{if(__cm_net>0)try{process.stderr.write('__CM_NET__:'+__cm_net+'\\n')}catch{}});
339
+ ;(function(__cm_req){
340
+ // Intercept globalThis.fetch
341
+ const __cm_f=globalThis.fetch;
288
342
  globalThis.fetch=async(...a)=>{const r=await __cm_f(...a);
289
343
  try{const cl=r.clone();const b=await cl.arrayBuffer();__cm_net+=b.byteLength}catch{}
290
344
  return r};
345
+ // Shadow CJS require with http/https network tracking.
346
+ const __cm_hc=new Map();
347
+ const __cm_hm=new Set(['http','https','node:http','node:https']);
348
+ function __cm_wf(m,origFn){return function(...a){
349
+ const li=a.length-1;
350
+ if(li>=0&&typeof a[li]==='function'){const oc=a[li];a[li]=function(res){
351
+ res.on('data',function(c){__cm_net+=c.length});oc(res);};}
352
+ const req=origFn.apply(m,a);
353
+ const oOn=req.on.bind(req);
354
+ req.on=function(ev,cb,...r){
355
+ if(ev==='response'){return oOn(ev,function(res){
356
+ res.on('data',function(c){__cm_net+=c.length});cb(res);
357
+ },...r);}
358
+ return oOn(ev,cb,...r);
359
+ };
360
+ return req;
361
+ }}
362
+ var require=__cm_req?function(id){
363
+ const m=__cm_req(id);
364
+ if(!__cm_hm.has(id))return m;
365
+ const k=id.replace('node:','');
366
+ if(__cm_hc.has(k))return __cm_hc.get(k);
367
+ const w=Object.create(m);
368
+ if(typeof m.get==='function')w.get=__cm_wf(m,m.get);
369
+ if(typeof m.request==='function')w.request=__cm_wf(m,m.request);
370
+ __cm_hc.set(k,w);return w;
371
+ }:__cm_req;
372
+ if(__cm_req){if(__cm_req.resolve)require.resolve=__cm_req.resolve;
373
+ if(__cm_req.cache)require.cache=__cm_req.cache;}
291
374
  async function __cm_main(){
292
375
  ${code}
293
376
  }
294
- __cm_main().catch(e=>{console.error(e);process.exitCode=1}).finally(()=>{
295
- if(__cm_net>0)process.stderr.write('__CM_NET__:'+__cm_net+'\\n');
296
- });`;
377
+ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nsetInterval(()=>{},2147483647);' : ''}
378
+ })(typeof require!=='undefined'?require:null);`;
297
379
  }
298
- const result = await executor.execute({ language, code: instrumentedCode, timeout });
380
+ const result = await executor.execute({ language, code: instrumentedCode, timeout, background });
299
381
  // Parse sandbox network metrics from stderr
300
382
  const netMatch = result.stderr?.match(/__CM_NET__:(\d+)/);
301
383
  if (netMatch) {
@@ -304,11 +386,34 @@ if(__cm_net>0)process.stderr.write('__CM_NET__:'+__cm_net+'\\n');
304
386
  result.stderr = result.stderr.replace(/\n?__CM_NET__:\d+\n?/g, "");
305
387
  }
306
388
  if (result.timedOut) {
307
- return trackResponse("execute", {
389
+ const partialOutput = result.stdout?.trim();
390
+ if (result.backgrounded && partialOutput) {
391
+ // Background mode: process is still running, return partial output as success
392
+ return trackResponse("ctx_execute", {
393
+ content: [
394
+ {
395
+ type: "text",
396
+ text: `${partialOutput}\n\n_(process backgrounded after ${timeout}ms — still running)_`,
397
+ },
398
+ ],
399
+ });
400
+ }
401
+ if (partialOutput) {
402
+ // Timeout with partial output — return as success with note
403
+ return trackResponse("ctx_execute", {
404
+ content: [
405
+ {
406
+ type: "text",
407
+ text: `${partialOutput}\n\n_(timed out after ${timeout}ms — partial output shown above)_`,
408
+ },
409
+ ],
410
+ });
411
+ }
412
+ return trackResponse("ctx_execute", {
308
413
  content: [
309
414
  {
310
415
  type: "text",
311
- text: `Execution timed out after ${timeout}ms\n\nPartial stdout:\n${result.stdout}\n\nstderr:\n${result.stderr}`,
416
+ text: `Execution timed out after ${timeout}ms\n\nstderr:\n${result.stderr}`,
312
417
  },
313
418
  ],
314
419
  isError: true,
@@ -318,14 +423,14 @@ if(__cm_net>0)process.stderr.write('__CM_NET__:'+__cm_net+'\\n');
318
423
  const output = `Exit code: ${result.exitCode}\n\nstdout:\n${result.stdout}\n\nstderr:\n${result.stderr}`;
319
424
  if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
320
425
  trackIndexed(Buffer.byteLength(output));
321
- return trackResponse("execute", {
426
+ return trackResponse("ctx_execute", {
322
427
  content: [
323
428
  { type: "text", text: intentSearch(output, intent, `execute:${language}:error`) },
324
429
  ],
325
430
  isError: true,
326
431
  });
327
432
  }
328
- return trackResponse("execute", {
433
+ return trackResponse("ctx_execute", {
329
434
  content: [
330
435
  { type: "text", text: output },
331
436
  ],
@@ -336,13 +441,13 @@ if(__cm_net>0)process.stderr.write('__CM_NET__:'+__cm_net+'\\n');
336
441
  // Intent-driven search: if intent provided and output is large enough
337
442
  if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
338
443
  trackIndexed(Buffer.byteLength(stdout));
339
- return trackResponse("execute", {
444
+ return trackResponse("ctx_execute", {
340
445
  content: [
341
446
  { type: "text", text: intentSearch(stdout, intent, `execute:${language}`) },
342
447
  ],
343
448
  });
344
449
  }
345
- return trackResponse("execute", {
450
+ return trackResponse("ctx_execute", {
346
451
  content: [
347
452
  { type: "text", text: stdout },
348
453
  ],
@@ -350,7 +455,7 @@ if(__cm_net>0)process.stderr.write('__CM_NET__:'+__cm_net+'\\n');
350
455
  }
351
456
  catch (err) {
352
457
  const message = err instanceof Error ? err.message : String(err);
353
- return trackResponse("execute", {
458
+ return trackResponse("ctx_execute", {
354
459
  content: [
355
460
  { type: "text", text: `Runtime error: ${message}` },
356
461
  ],
@@ -422,7 +527,7 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
422
527
  // ─────────────────────────────────────────────────────────
423
528
  // Tool: execute_file
424
529
  // ─────────────────────────────────────────────────────────
425
- server.registerTool("execute_file", {
530
+ server.registerTool("ctx_execute_file", {
426
531
  title: "Execute File Processing",
427
532
  description: "Read a file and process it without loading contents into context. The file is read into a FILE_CONTENT variable inside the sandbox. Only your printed summary enters context.\n\nPREFER THIS OVER Read/cat for: log files, data files (CSV, JSON, XML), large source files for analysis, and any file where you need to extract specific information rather than read the entire content.",
428
533
  inputSchema: z.object({
@@ -482,7 +587,7 @@ server.registerTool("execute_file", {
482
587
  timeout,
483
588
  });
484
589
  if (result.timedOut) {
485
- return trackResponse("execute_file", {
590
+ return trackResponse("ctx_execute_file", {
486
591
  content: [
487
592
  {
488
593
  type: "text",
@@ -496,14 +601,14 @@ server.registerTool("execute_file", {
496
601
  const output = `Error processing ${path} (exit ${result.exitCode}):\n${result.stderr || result.stdout}`;
497
602
  if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
498
603
  trackIndexed(Buffer.byteLength(output));
499
- return trackResponse("execute_file", {
604
+ return trackResponse("ctx_execute_file", {
500
605
  content: [
501
606
  { type: "text", text: intentSearch(output, intent, `file:${path}:error`) },
502
607
  ],
503
608
  isError: true,
504
609
  });
505
610
  }
506
- return trackResponse("execute_file", {
611
+ return trackResponse("ctx_execute_file", {
507
612
  content: [
508
613
  { type: "text", text: output },
509
614
  ],
@@ -513,13 +618,13 @@ server.registerTool("execute_file", {
513
618
  const stdout = result.stdout || "(no output)";
514
619
  if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
515
620
  trackIndexed(Buffer.byteLength(stdout));
516
- return trackResponse("execute_file", {
621
+ return trackResponse("ctx_execute_file", {
517
622
  content: [
518
623
  { type: "text", text: intentSearch(stdout, intent, `file:${path}`) },
519
624
  ],
520
625
  });
521
626
  }
522
- return trackResponse("execute_file", {
627
+ return trackResponse("ctx_execute_file", {
523
628
  content: [
524
629
  { type: "text", text: stdout },
525
630
  ],
@@ -527,7 +632,7 @@ server.registerTool("execute_file", {
527
632
  }
528
633
  catch (err) {
529
634
  const message = err instanceof Error ? err.message : String(err);
530
- return trackResponse("execute_file", {
635
+ return trackResponse("ctx_execute_file", {
531
636
  content: [
532
637
  { type: "text", text: `Runtime error: ${message}` },
533
638
  ],
@@ -538,7 +643,7 @@ server.registerTool("execute_file", {
538
643
  // ─────────────────────────────────────────────────────────
539
644
  // Tool: index
540
645
  // ─────────────────────────────────────────────────────────
541
- server.registerTool("index", {
646
+ server.registerTool("ctx_index", {
542
647
  title: "Index Content",
543
648
  description: "Index documentation or knowledge content into a searchable BM25 knowledge base. " +
544
649
  "Chunks markdown by headings (keeping code blocks intact) and stores in ephemeral FTS5 database. " +
@@ -568,7 +673,7 @@ server.registerTool("index", {
568
673
  }),
569
674
  }, async ({ content, path, source }) => {
570
675
  if (!content && !path) {
571
- return trackResponse("index", {
676
+ return trackResponse("ctx_index", {
572
677
  content: [
573
678
  {
574
679
  type: "text",
@@ -591,7 +696,7 @@ server.registerTool("index", {
591
696
  }
592
697
  const store = getStore();
593
698
  const result = store.index({ content, path, source });
594
- return trackResponse("index", {
699
+ return trackResponse("ctx_index", {
595
700
  content: [
596
701
  {
597
702
  type: "text",
@@ -602,7 +707,7 @@ server.registerTool("index", {
602
707
  }
603
708
  catch (err) {
604
709
  const message = err instanceof Error ? err.message : String(err);
605
- return trackResponse("index", {
710
+ return trackResponse("ctx_index", {
606
711
  content: [
607
712
  { type: "text", text: `Index error: ${message}` },
608
713
  ],
@@ -619,7 +724,7 @@ let searchWindowStart = Date.now();
619
724
  const SEARCH_WINDOW_MS = 60_000;
620
725
  const SEARCH_MAX_RESULTS_AFTER = 3; // after 3 calls: 1 result per query
621
726
  const SEARCH_BLOCK_AFTER = 8; // after 8 calls: refuse, demand batching
622
- server.registerTool("search", {
727
+ server.registerTool("ctx_search", {
623
728
  title: "Search Indexed Content",
624
729
  description: "Search indexed content. Pass ALL search questions as queries array in ONE call.\n\n" +
625
730
  "TIPS: 2-4 specific terms per query. Use 'source' to scope results.",
@@ -651,7 +756,7 @@ server.registerTool("search", {
651
756
  queryList.push(raw.query);
652
757
  }
653
758
  if (queryList.length === 0) {
654
- return trackResponse("search", {
759
+ return trackResponse("ctx_search", {
655
760
  content: [{ type: "text", text: "Error: provide query or queries." }],
656
761
  isError: true,
657
762
  });
@@ -666,7 +771,7 @@ server.registerTool("search", {
666
771
  searchCallCount++;
667
772
  // After SEARCH_BLOCK_AFTER calls: refuse
668
773
  if (searchCallCount > SEARCH_BLOCK_AFTER) {
669
- return trackResponse("search", {
774
+ return trackResponse("ctx_search", {
670
775
  content: [{
671
776
  type: "text",
672
777
  text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now - searchWindowStart) / 1000)}s. ` +
@@ -716,17 +821,17 @@ server.registerTool("search", {
716
821
  const sourceList = sources.length > 0
717
822
  ? `\nIndexed sources: ${sources.map((s) => `"${s.label}" (${s.chunkCount} sections)`).join(", ")}`
718
823
  : "";
719
- return trackResponse("search", {
824
+ return trackResponse("ctx_search", {
720
825
  content: [{ type: "text", text: `No results found.${sourceList}` }],
721
826
  });
722
827
  }
723
- return trackResponse("search", {
828
+ return trackResponse("ctx_search", {
724
829
  content: [{ type: "text", text: output }],
725
830
  });
726
831
  }
727
832
  catch (err) {
728
833
  const message = err instanceof Error ? err.message : String(err);
729
- return trackResponse("search", {
834
+ return trackResponse("ctx_search", {
730
835
  content: [{ type: "text", text: `Search error: ${message}` }],
731
836
  isError: true,
732
837
  });
@@ -757,13 +862,23 @@ function resolveGfmPluginPath() {
757
862
  // Subprocess code that fetches a URL, detects Content-Type, and outputs a
758
863
  // __CM_CT__:<type> marker on the first line so the handler can route to the
759
864
  // appropriate indexing strategy. HTML is converted to markdown via Turndown.
760
- function buildFetchCode(url) {
865
+ function buildFetchCode(url, outputPath) {
761
866
  const turndownPath = JSON.stringify(resolveTurndownPath());
762
867
  const gfmPath = JSON.stringify(resolveGfmPluginPath());
868
+ const escapedOutputPath = JSON.stringify(outputPath);
763
869
  return `
764
870
  const TurndownService = require(${turndownPath});
765
871
  const { gfm } = require(${gfmPath});
872
+ const fs = require('fs');
766
873
  const url = ${JSON.stringify(url)};
874
+ const outputPath = ${escapedOutputPath};
875
+
876
+ function emit(ct, content) {
877
+ // Write content to file to bypass executor stdout truncation (100KB limit).
878
+ // Only the content-type marker goes to stdout.
879
+ fs.writeFileSync(outputPath, content);
880
+ console.log('__CM_CT__:' + ct);
881
+ }
767
882
 
768
883
  async function main() {
769
884
  const resp = await fetch(url);
@@ -775,12 +890,9 @@ async function main() {
775
890
  const text = await resp.text();
776
891
  try {
777
892
  const pretty = JSON.stringify(JSON.parse(text), null, 2);
778
- console.log('__CM_CT__:json');
779
- console.log(pretty);
893
+ emit('json', pretty);
780
894
  } catch {
781
- // Unparseable "JSON" — fall back to plain text
782
- console.log('__CM_CT__:text');
783
- console.log(text);
895
+ emit('text', text);
784
896
  }
785
897
  return;
786
898
  }
@@ -791,20 +903,18 @@ async function main() {
791
903
  const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
792
904
  td.use(gfm);
793
905
  td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
794
- console.log('__CM_CT__:html');
795
- console.log(td.turndown(html));
906
+ emit('html', td.turndown(html));
796
907
  return;
797
908
  }
798
909
 
799
910
  // --- Everything else: plain text, CSV, XML, etc. ---
800
911
  const text = await resp.text();
801
- console.log('__CM_CT__:text');
802
- console.log(text);
912
+ emit('text', text);
803
913
  }
804
914
  main();
805
915
  `;
806
916
  }
807
- server.registerTool("fetch_and_index", {
917
+ server.registerTool("ctx_fetch_and_index", {
808
918
  title: "Fetch & Index URL",
809
919
  description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
810
920
  "and returns a ~3KB preview. Full content stays in sandbox — use search() for deeper lookups.\n\n" +
@@ -818,16 +928,18 @@ server.registerTool("fetch_and_index", {
818
928
  .describe("Label for the indexed content (e.g., 'React useEffect docs', 'Supabase Auth API')"),
819
929
  }),
820
930
  }, async ({ url, source }) => {
931
+ // Generate a unique temp file path for the subprocess to write fetched content.
932
+ // This bypasses the executor's 100KB stdout truncation — content goes file→handler directly.
933
+ const outputPath = join(tmpdir(), `ctx-fetch-${Date.now()}-${Math.random().toString(36).slice(2)}.dat`);
821
934
  try {
822
- // Execute fetch inside subprocess — raw HTML never enters context
823
- const fetchCode = buildFetchCode(url);
935
+ const fetchCode = buildFetchCode(url, outputPath);
824
936
  const result = await executor.execute({
825
937
  language: "javascript",
826
938
  code: fetchCode,
827
939
  timeout: 30_000,
828
940
  });
829
941
  if (result.exitCode !== 0) {
830
- return trackResponse("fetch_and_index", {
942
+ return trackResponse("ctx_fetch_and_index", {
831
943
  content: [
832
944
  {
833
945
  type: "text",
@@ -837,15 +949,27 @@ server.registerTool("fetch_and_index", {
837
949
  isError: true,
838
950
  });
839
951
  }
840
- // Parse content-type marker from subprocess output
952
+ // Parse content-type marker from stdout (content is in the temp file)
841
953
  const store = getStore();
842
- const rawOutput = (result.stdout || "").trim();
843
- const firstNewline = rawOutput.indexOf("\n");
844
- const header = firstNewline >= 0 ? rawOutput.slice(0, firstNewline) : "";
845
- const content = firstNewline >= 0 ? rawOutput.slice(firstNewline + 1) : rawOutput;
846
- const markdown = content.trim();
954
+ const header = (result.stdout || "").trim();
955
+ // Read full content from temp file (bypasses smartTruncate)
956
+ let markdown;
957
+ try {
958
+ markdown = readFileSync(outputPath, "utf-8").trim();
959
+ }
960
+ catch {
961
+ return trackResponse("ctx_fetch_and_index", {
962
+ content: [
963
+ {
964
+ type: "text",
965
+ text: `Fetched ${url} but could not read subprocess output`,
966
+ },
967
+ ],
968
+ isError: true,
969
+ });
970
+ }
847
971
  if (markdown.length === 0) {
848
- return trackResponse("fetch_and_index", {
972
+ return trackResponse("ctx_fetch_and_index", {
849
973
  content: [
850
974
  {
851
975
  type: "text",
@@ -882,24 +1006,31 @@ server.registerTool("fetch_and_index", {
882
1006
  "",
883
1007
  preview,
884
1008
  ].join("\n");
885
- return trackResponse("fetch_and_index", {
1009
+ return trackResponse("ctx_fetch_and_index", {
886
1010
  content: [{ type: "text", text }],
887
1011
  });
888
1012
  }
889
1013
  catch (err) {
890
1014
  const message = err instanceof Error ? err.message : String(err);
891
- return trackResponse("fetch_and_index", {
1015
+ return trackResponse("ctx_fetch_and_index", {
892
1016
  content: [
893
1017
  { type: "text", text: `Fetch error: ${message}` },
894
1018
  ],
895
1019
  isError: true,
896
1020
  });
897
1021
  }
1022
+ finally {
1023
+ // Clean up temp file
1024
+ try {
1025
+ rmSync(outputPath);
1026
+ }
1027
+ catch { /* already gone */ }
1028
+ }
898
1029
  });
899
1030
  // ─────────────────────────────────────────────────────────
900
1031
  // Tool: batch_execute
901
1032
  // ─────────────────────────────────────────────────────────
902
- server.registerTool("batch_execute", {
1033
+ server.registerTool("ctx_batch_execute", {
903
1034
  title: "Batch Execute & Search",
904
1035
  description: "Execute multiple commands in ONE call, auto-index all output, and search with multiple queries. " +
905
1036
  "Returns search results directly — no follow-up calls needed.\n\n" +
@@ -938,32 +1069,52 @@ server.registerTool("batch_execute", {
938
1069
  return denied;
939
1070
  }
940
1071
  try {
941
- // Build batch script with markdown section headers for proper chunking
942
- const script = commands
943
- .map((c) => {
944
- const safeLabel = c.label.replace(/'/g, "'\\''");
945
- return `echo '# ${safeLabel}'\necho ''\n${c.command} 2>&1\necho ''`;
946
- })
947
- .join("\n");
948
- const result = await executor.execute({
949
- language: "shell",
950
- code: script,
951
- timeout,
952
- });
953
- if (result.timedOut) {
954
- return trackResponse("batch_execute", {
1072
+ // Execute each command individually so every command gets its own
1073
+ // smartTruncate budget (~100KB). Previously, all commands were
1074
+ // concatenated into a single script where smartTruncate (60% head +
1075
+ // 40% tail) could silently drop middle commands. (Issue #61)
1076
+ const perCommandOutputs = [];
1077
+ const startTime = Date.now();
1078
+ let timedOut = false;
1079
+ for (const cmd of commands) {
1080
+ const elapsed = Date.now() - startTime;
1081
+ const remaining = timeout - elapsed;
1082
+ if (remaining <= 0) {
1083
+ perCommandOutputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`);
1084
+ timedOut = true;
1085
+ continue;
1086
+ }
1087
+ const result = await executor.execute({
1088
+ language: "shell",
1089
+ code: `${cmd.command} 2>&1`,
1090
+ timeout: remaining,
1091
+ });
1092
+ const output = result.stdout || "(no output)";
1093
+ perCommandOutputs.push(`# ${cmd.label}\n\n${output}\n`);
1094
+ if (result.timedOut) {
1095
+ timedOut = true;
1096
+ // Mark remaining commands as skipped
1097
+ const idx = commands.indexOf(cmd);
1098
+ for (let i = idx + 1; i < commands.length; i++) {
1099
+ perCommandOutputs.push(`# ${commands[i].label}\n\n(skipped — batch timeout exceeded)\n`);
1100
+ }
1101
+ break;
1102
+ }
1103
+ }
1104
+ const stdout = perCommandOutputs.join("\n");
1105
+ const totalBytes = Buffer.byteLength(stdout);
1106
+ const totalLines = stdout.split("\n").length;
1107
+ if (timedOut && perCommandOutputs.length === 0) {
1108
+ return trackResponse("ctx_batch_execute", {
955
1109
  content: [
956
1110
  {
957
1111
  type: "text",
958
- text: `Batch timed out after ${timeout}ms. Partial output:\n${result.stdout?.slice(0, 2000) || "(none)"}`,
1112
+ text: `Batch timed out after ${timeout}ms. No output captured.`,
959
1113
  },
960
1114
  ],
961
1115
  isError: true,
962
1116
  });
963
1117
  }
964
- const stdout = result.stdout || "(no output)";
965
- const totalBytes = Buffer.byteLength(stdout);
966
- const totalLines = stdout.split("\n").length;
967
1118
  // Track indexed bytes (raw data that stays in sandbox)
968
1119
  trackIndexed(totalBytes);
969
1120
  // Index into knowledge base — markdown heading chunking splits by # labels
@@ -994,16 +1145,23 @@ server.registerTool("batch_execute", {
994
1145
  }
995
1146
  // Tier 1: scoped search with fallback (porter → trigram → fuzzy)
996
1147
  let results = store.searchWithFallback(query, 3, source);
997
- // Tier 2: global fallback (no source filter)
1148
+ let crossSource = false;
1149
+ // Tier 2: global fallback (no source filter) — warn about cross-source (Issue #61)
998
1150
  if (results.length === 0) {
999
1151
  results = store.searchWithFallback(query, 3);
1152
+ crossSource = results.length > 0;
1000
1153
  }
1001
1154
  queryResults.push(`## ${query}`);
1155
+ if (crossSource) {
1156
+ queryResults.push(`> **Note:** No results in current batch output. Showing results from previously indexed content.`);
1157
+ }
1002
1158
  queryResults.push("");
1003
1159
  if (results.length > 0) {
1004
1160
  for (const r of results) {
1005
- const snippet = extractSnippet(r.content, query, 1500, r.highlighted);
1006
- queryResults.push(`### ${r.title}`);
1161
+ // Use larger snippet (3KB) for batch_execute to reduce tiny-fragment issue (Issue #61)
1162
+ const snippet = extractSnippet(r.content, query, 3000, r.highlighted);
1163
+ const sourceTag = crossSource ? ` _(source: ${r.source})_` : "";
1164
+ queryResults.push(`### ${r.title}${sourceTag}`);
1007
1165
  queryResults.push(snippet);
1008
1166
  queryResults.push("");
1009
1167
  outputSize += snippet.length + r.title.length;
@@ -1029,13 +1187,13 @@ server.registerTool("batch_execute", {
1029
1187
  ? `\nSearchable terms for follow-up: ${distinctiveTerms.join(", ")}`
1030
1188
  : "",
1031
1189
  ].join("\n");
1032
- return trackResponse("batch_execute", {
1190
+ return trackResponse("ctx_batch_execute", {
1033
1191
  content: [{ type: "text", text: output }],
1034
1192
  });
1035
1193
  }
1036
1194
  catch (err) {
1037
1195
  const message = err instanceof Error ? err.message : String(err);
1038
- return trackResponse("batch_execute", {
1196
+ return trackResponse("ctx_batch_execute", {
1039
1197
  content: [
1040
1198
  {
1041
1199
  type: "text",
@@ -1049,7 +1207,7 @@ server.registerTool("batch_execute", {
1049
1207
  // ─────────────────────────────────────────────────────────
1050
1208
  // Tool: stats
1051
1209
  // ─────────────────────────────────────────────────────────
1052
- server.registerTool("stats", {
1210
+ server.registerTool("ctx_stats", {
1053
1211
  title: "Session Statistics",
1054
1212
  description: "Returns context consumption statistics for the current session. " +
1055
1213
  "Shows total bytes returned to context, breakdown by tool, call counts, " +
@@ -1072,48 +1230,208 @@ server.registerTool("stats", {
1072
1230
  return `${(b / 1024 / 1024).toFixed(1)}MB`;
1073
1231
  return `${(b / 1024).toFixed(1)}KB`;
1074
1232
  };
1075
- // ── Summary table ──
1233
+ // ── Header ──
1076
1234
  const lines = [
1077
- `## context-mode session stats`,
1078
- "",
1079
- `| Metric | Value |`,
1080
- `|--------|------:|`,
1081
- `| Session | ${uptimeMin} min |`,
1082
- `| Tool calls | ${totalCalls} |`,
1083
- `| Total data processed | **${kb(totalProcessed)}** |`,
1084
- `| Kept in sandbox | **${kb(keptOut)}** |`,
1085
- `| Entered context | ${kb(totalBytesReturned)} |`,
1086
- `| Tokens consumed | ~${Math.round(totalBytesReturned / 4).toLocaleString()} |`,
1087
- `| **Context savings** | **${savingsRatio.toFixed(1)}x (${reductionPct}% reduction)** |`,
1235
+ `## context-mode Session Report (${uptimeMin} min)`,
1088
1236
  ];
1089
- // ── Per-tool table ──
1090
- const toolNames = new Set([
1091
- ...Object.keys(sessionStats.calls),
1092
- ...Object.keys(sessionStats.bytesReturned),
1093
- ]);
1094
- if (toolNames.size > 0) {
1095
- lines.push("", `| Tool | Calls | Context | Tokens |`, `|------|------:|--------:|-------:|`);
1096
- for (const tool of Array.from(toolNames).sort()) {
1097
- const calls = sessionStats.calls[tool] || 0;
1098
- const bytes = sessionStats.bytesReturned[tool] || 0;
1099
- const tokens = Math.round(bytes / 4);
1100
- lines.push(`| ${tool} | ${calls} | ${kb(bytes)} | ~${tokens.toLocaleString()} |`);
1101
- }
1102
- lines.push(`| **Total** | **${totalCalls}** | **${kb(totalBytesReturned)}** | **~${Math.round(totalBytesReturned / 4).toLocaleString()}** |`);
1103
- }
1104
- // ── DevRel summary ──
1105
- const tokensSaved = Math.round(keptOut / 4);
1237
+ // ── Feature 1: Context Window Protection ──
1238
+ lines.push("", `### Context Window Protection`, "");
1106
1239
  if (totalCalls === 0) {
1107
- lines.push("", "> No context-mode calls this session. Use `batch_execute` to run commands, `fetch_and_index` for URLs, or `execute` to process data in sandbox.");
1108
- }
1109
- else if (keptOut === 0) {
1110
- lines.push("", `> context-mode handled **${totalCalls}** tool calls. All outputs were compact enough to enter context directly. Process larger data or batch multiple commands for bigger savings.`);
1240
+ lines.push(`No context-mode tool calls yet. Use \`batch_execute\`, \`execute\`, or \`fetch_and_index\` to keep raw output out of your context window.`);
1111
1241
  }
1112
1242
  else {
1113
- lines.push("", `> Without context-mode, **${kb(totalProcessed)}** of raw tool output would flood your context window. Instead, **${kb(keptOut)}** (${reductionPct}%) stayed in sandbox saving **~${tokensSaved.toLocaleString()} tokens** of context space.`);
1243
+ lines.push(`| Metric | Value |`, `|--------|------:|`, `| Total data processed | **${kb(totalProcessed)}** |`, `| Kept in sandbox (never entered context) | **${kb(keptOut)}** |`, `| Entered context | ${kb(totalBytesReturned)} |`, `| Estimated tokens saved | ~${Math.round(keptOut / 4).toLocaleString()} |`, `| **Context savings** | **${savingsRatio.toFixed(1)}x (${reductionPct}% reduction)** |`);
1244
+ // Per-tool breakdown
1245
+ const toolNames = new Set([
1246
+ ...Object.keys(sessionStats.calls),
1247
+ ...Object.keys(sessionStats.bytesReturned),
1248
+ ]);
1249
+ if (toolNames.size > 0) {
1250
+ lines.push("", `| Tool | Calls | Context | Tokens |`, `|------|------:|--------:|-------:|`);
1251
+ for (const tool of Array.from(toolNames).sort()) {
1252
+ const calls = sessionStats.calls[tool] || 0;
1253
+ const bytes = sessionStats.bytesReturned[tool] || 0;
1254
+ const tokens = Math.round(bytes / 4);
1255
+ lines.push(`| ${tool} | ${calls} | ${kb(bytes)} | ~${tokens.toLocaleString()} |`);
1256
+ }
1257
+ lines.push(`| **Total** | **${totalCalls}** | **${kb(totalBytesReturned)}** | **~${Math.round(totalBytesReturned / 4).toLocaleString()}** |`);
1258
+ }
1259
+ if (keptOut > 0) {
1260
+ lines.push("", `Without context-mode, **${kb(totalProcessed)}** of raw output would flood your context window. Instead, **${reductionPct}%** stayed in sandbox.`);
1261
+ }
1262
+ }
1263
+ // ── Session Continuity ──
1264
+ try {
1265
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
1266
+ const dbHash = createHash("sha256").update(projectDir).digest("hex").slice(0, 16);
1267
+ const sessionDbPath = join(homedir(), ".claude", "context-mode", "sessions", `${dbHash}.db`);
1268
+ if (existsSync(sessionDbPath)) {
1269
+ const require = createRequire(import.meta.url);
1270
+ const Database = require("better-sqlite3");
1271
+ const sdb = new Database(sessionDbPath, { readonly: true });
1272
+ const eventTotal = sdb.prepare("SELECT COUNT(*) as cnt FROM session_events").get();
1273
+ const byCategory = sdb.prepare("SELECT category, COUNT(*) as cnt FROM session_events GROUP BY category ORDER BY cnt DESC").all();
1274
+ const meta = sdb.prepare("SELECT compact_count FROM session_meta ORDER BY started_at DESC LIMIT 1").get();
1275
+ const resume = sdb.prepare("SELECT event_count, consumed FROM session_resume ORDER BY created_at DESC LIMIT 1").get();
1276
+ if (eventTotal.cnt > 0) {
1277
+ const compacts = meta?.compact_count ?? 0;
1278
+ // Query actual data per category for preview
1279
+ const previewRows = sdb.prepare(`SELECT category, type, data FROM session_events ORDER BY id DESC`).all();
1280
+ // Build previews: unique values per category
1281
+ const previews = new Map();
1282
+ for (const row of previewRows) {
1283
+ if (!previews.has(row.category))
1284
+ previews.set(row.category, new Set());
1285
+ const set = previews.get(row.category);
1286
+ if (set.size < 5) {
1287
+ let display = row.data;
1288
+ if (row.category === "file") {
1289
+ display = row.data.split("/").pop() || row.data;
1290
+ }
1291
+ else if (row.category === "prompt") {
1292
+ display = display.length > 50 ? display.slice(0, 47) + "..." : display;
1293
+ }
1294
+ if (display.length > 40)
1295
+ display = display.slice(0, 37) + "...";
1296
+ set.add(display);
1297
+ }
1298
+ }
1299
+ const categoryLabels = {
1300
+ file: "Files tracked",
1301
+ rule: "Project rules (CLAUDE.md)",
1302
+ prompt: "Your requests saved",
1303
+ mcp: "Plugin tools used",
1304
+ git: "Git operations",
1305
+ env: "Environment setup",
1306
+ error: "Errors caught",
1307
+ task: "Tasks in progress",
1308
+ decision: "Your decisions",
1309
+ cwd: "Working directory",
1310
+ skill: "Skills used",
1311
+ subagent: "Delegated work",
1312
+ intent: "Session mode",
1313
+ data: "Data references",
1314
+ role: "Behavioral directives",
1315
+ };
1316
+ const categoryHints = {
1317
+ file: "Restored after compact — no need to re-read",
1318
+ rule: "Your project instructions survive context resets",
1319
+ prompt: "Continues exactly where you left off",
1320
+ decision: "Applied automatically — won't ask again",
1321
+ task: "Picks up from where it stopped",
1322
+ error: "Tracked and monitored across compacts",
1323
+ git: "Branch, commit, and repo state preserved",
1324
+ env: "Runtime config carried forward",
1325
+ mcp: "Tool usage patterns remembered",
1326
+ subagent: "Delegation history preserved",
1327
+ skill: "Skill invocations tracked",
1328
+ };
1329
+ lines.push("", "### Session Continuity", "", "| What's preserved | Count | I remember... | Why it matters |", "|------------------|------:|---------------|----------------|");
1330
+ for (const row of byCategory) {
1331
+ const label = categoryLabels[row.category] || row.category;
1332
+ const preview = previews.get(row.category);
1333
+ const previewStr = preview ? Array.from(preview).join(", ") : "";
1334
+ const hint = categoryHints[row.category] || "Survives context resets";
1335
+ lines.push(`| ${label} | ${row.cnt} | ${previewStr} | ${hint} |`);
1336
+ }
1337
+ lines.push(`| **Total** | **${eventTotal.cnt}** | | **Zero knowledge lost on compact** |`);
1338
+ lines.push("");
1339
+ if (compacts > 0) {
1340
+ lines.push(`Context has been compacted **${compacts} time(s)** — session knowledge was preserved each time.`);
1341
+ }
1342
+ else {
1343
+ lines.push(`When your context compacts, all of this will restore Claude's awareness — no starting from scratch.`);
1344
+ }
1345
+ if (resume && !resume.consumed) {
1346
+ lines.push(`Resume snapshot ready (${resume.event_count} events) for the next compaction.`);
1347
+ }
1348
+ lines.push("");
1349
+ lines.push(`> **Note:** Previous session data is loaded when you start a new session. Without \`--continue\`, old session history is cleaned up to keep the database lean.`);
1350
+ }
1351
+ sdb.close();
1352
+ }
1114
1353
  }
1354
+ catch {
1355
+ // Session DB not available or incompatible — skip silently
1356
+ }
1357
+ // No separate DevRel summary — integrated into feature sections above
1358
+ // Formatting directive — ensures all LLMs display the report verbatim
1359
+ lines.push("", "---", "_Display this entire report as-is in your response. Do NOT summarize, collapse, or paraphrase any section._");
1115
1360
  const text = lines.join("\n");
1116
- return trackResponse("stats", {
1361
+ return trackResponse("ctx_stats", {
1362
+ content: [{ type: "text", text }],
1363
+ });
1364
+ });
1365
+ // ── ctx-doctor: diagnostics meta-tool ──────────────────────────────────────
1366
+ server.registerTool("ctx_doctor", {
1367
+ title: "Run Diagnostics",
1368
+ description: "Diagnose context-mode installation. Returns a shell command to execute. " +
1369
+ "You MUST run the returned command using your shell tool (Bash, shell_execute, " +
1370
+ "run_in_terminal, etc.) and display the output as a markdown checklist.",
1371
+ inputSchema: z.object({}),
1372
+ }, async () => {
1373
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
1374
+ const cmd = `node "${pluginRoot}/build/cli.js" doctor`;
1375
+ const text = [
1376
+ "## ctx-doctor",
1377
+ "",
1378
+ "Run this command using your shell execution tool:",
1379
+ "",
1380
+ "```",
1381
+ cmd,
1382
+ "```",
1383
+ "",
1384
+ "After the command completes, display results as a markdown checklist:",
1385
+ "- `[x]` for PASS, `[ ]` for FAIL, `[-]` for WARN",
1386
+ "- Example format:",
1387
+ " ```",
1388
+ " ## context-mode doctor",
1389
+ " - [x] Runtimes: 6/10 (javascript, typescript, python, shell, ruby, perl)",
1390
+ " - [x] Performance: FAST (Bun)",
1391
+ " - [x] Server test: PASS",
1392
+ " - [x] Hooks: PASS",
1393
+ " - [x] FTS5: PASS",
1394
+ " - [x] npm: v0.9.23",
1395
+ " ```",
1396
+ ].join("\n");
1397
+ return trackResponse("ctx_doctor", {
1398
+ content: [{ type: "text", text }],
1399
+ });
1400
+ });
1401
+ // ── ctx-upgrade: upgrade meta-tool ─────────────────────────────────────────
1402
+ server.registerTool("ctx_upgrade", {
1403
+ title: "Upgrade Plugin",
1404
+ description: "Upgrade context-mode to the latest version. Returns a shell command to execute. " +
1405
+ "You MUST run the returned command using your shell tool (Bash, shell_execute, " +
1406
+ "run_in_terminal, etc.) and display the output as a checklist. " +
1407
+ "Tell the user to restart their session after upgrade.",
1408
+ inputSchema: z.object({}),
1409
+ }, async () => {
1410
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
1411
+ const cmd = `node "${pluginRoot}/build/cli.js" upgrade`;
1412
+ const text = [
1413
+ "## ctx-upgrade",
1414
+ "",
1415
+ "Run this command using your shell execution tool:",
1416
+ "",
1417
+ "```",
1418
+ cmd,
1419
+ "```",
1420
+ "",
1421
+ "After the command completes, display results as a markdown checklist:",
1422
+ "- `[x]` for success, `[ ]` for failure",
1423
+ "- Example format:",
1424
+ " ```",
1425
+ " ## context-mode upgrade",
1426
+ " - [x] Pulled latest from GitHub",
1427
+ " - [x] Built and installed v0.9.24",
1428
+ " - [x] npm global updated",
1429
+ " - [x] Hooks configured",
1430
+ " - [x] Doctor: all checks PASS",
1431
+ " ```",
1432
+ "- Tell the user to restart their session to pick up the new version.",
1433
+ ].join("\n");
1434
+ return trackResponse("ctx_upgrade", {
1117
1435
  content: [{ type: "text", text }],
1118
1436
  });
1119
1437
  });
@@ -1126,8 +1444,9 @@ async function main() {
1126
1444
  if (cleaned > 0) {
1127
1445
  console.error(`Cleaned up ${cleaned} stale DB file(s) from previous sessions`);
1128
1446
  }
1129
- // Clean up own DB on shutdown
1447
+ // Clean up own DB + backgrounded processes on shutdown
1130
1448
  const shutdown = () => {
1449
+ executor.cleanupBackgrounded();
1131
1450
  if (_store)
1132
1451
  _store.cleanup();
1133
1452
  };