context-mode 1.0.150 → 1.0.152

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 (107) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/mcp.json +5 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.openclaw-plugin/openclaw.plugin.json +16 -1
  6. package/.openclaw-plugin/package.json +1 -1
  7. package/README.md +89 -3
  8. package/build/adapters/claude-code/hooks.js +2 -2
  9. package/build/adapters/claude-code/index.js +14 -13
  10. package/build/adapters/client-map.js +3 -0
  11. package/build/adapters/detect.js +13 -1
  12. package/build/adapters/gemini-cli/hooks.d.ts +10 -0
  13. package/build/adapters/gemini-cli/hooks.js +12 -2
  14. package/build/adapters/gemini-cli/index.d.ts +21 -1
  15. package/build/adapters/gemini-cli/index.js +37 -1
  16. package/build/adapters/kimi/config.d.ts +8 -0
  17. package/build/adapters/kimi/config.js +8 -0
  18. package/build/adapters/kimi/hooks.d.ts +28 -0
  19. package/build/adapters/kimi/hooks.js +34 -0
  20. package/build/adapters/kimi/index.d.ts +66 -0
  21. package/build/adapters/kimi/index.js +537 -0
  22. package/build/adapters/kimi/paths.d.ts +1 -0
  23. package/build/adapters/kimi/paths.js +12 -0
  24. package/build/adapters/kiro/hooks.js +2 -2
  25. package/build/adapters/openclaw/plugin.d.ts +14 -13
  26. package/build/adapters/openclaw/plugin.js +140 -40
  27. package/build/adapters/opencode/plugin.js +4 -3
  28. package/build/adapters/opencode/zod3tov4.js +8 -8
  29. package/build/adapters/pi/extension.js +9 -24
  30. package/build/adapters/pi/mcp-bridge.js +37 -0
  31. package/build/adapters/qwen-code/index.js +7 -7
  32. package/build/adapters/types.d.ts +39 -2
  33. package/build/adapters/types.js +55 -2
  34. package/build/adapters/vscode-copilot/index.js +13 -1
  35. package/build/cli.js +433 -25
  36. package/build/executor.js +6 -3
  37. package/build/runtime.d.ts +81 -1
  38. package/build/runtime.js +195 -9
  39. package/build/search/ctx-search-schema.d.ts +90 -0
  40. package/build/search/ctx-search-schema.js +135 -0
  41. package/build/search/unified.d.ts +12 -0
  42. package/build/search/unified.js +17 -2
  43. package/build/server.d.ts +2 -1
  44. package/build/server.js +378 -97
  45. package/build/session/analytics.d.ts +36 -13
  46. package/build/session/analytics.js +123 -26
  47. package/build/session/db.d.ts +24 -0
  48. package/build/session/db.js +41 -0
  49. package/build/session/extract.js +30 -0
  50. package/build/session/snapshot.js +24 -0
  51. package/build/store.d.ts +12 -1
  52. package/build/store.js +72 -20
  53. package/build/types.d.ts +7 -0
  54. package/build/util/project-dir.d.ts +19 -16
  55. package/build/util/project-dir.js +80 -45
  56. package/cli.bundle.mjs +371 -320
  57. package/configs/kimi/hooks.json +54 -0
  58. package/configs/pi/AGENTS.md +3 -85
  59. package/hooks/cache-heal-utils.mjs +148 -0
  60. package/hooks/core/formatters.mjs +26 -0
  61. package/hooks/core/routing.mjs +9 -1
  62. package/hooks/core/stdin.mjs +74 -3
  63. package/hooks/core/tool-naming.mjs +1 -0
  64. package/hooks/heal-partial-install.mjs +712 -0
  65. package/hooks/kimi/platform.mjs +1 -0
  66. package/hooks/kimi/posttooluse.mjs +72 -0
  67. package/hooks/kimi/precompact.mjs +80 -0
  68. package/hooks/kimi/pretooluse.mjs +42 -0
  69. package/hooks/kimi/sessionend.mjs +61 -0
  70. package/hooks/kimi/sessionstart.mjs +113 -0
  71. package/hooks/kimi/stop.mjs +61 -0
  72. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  73. package/hooks/normalize-hooks.mjs +66 -12
  74. package/hooks/routing-block.mjs +8 -2
  75. package/hooks/security.bundle.mjs +1 -1
  76. package/hooks/session-db.bundle.mjs +6 -4
  77. package/hooks/session-extract.bundle.mjs +2 -2
  78. package/hooks/session-helpers.mjs +93 -3
  79. package/hooks/session-snapshot.bundle.mjs +20 -19
  80. package/hooks/sessionstart.mjs +64 -0
  81. package/insight/server.mjs +15 -3
  82. package/openclaw.plugin.json +16 -1
  83. package/package.json +1 -1
  84. package/scripts/heal-installed-plugins.mjs +31 -10
  85. package/scripts/postinstall.mjs +10 -0
  86. package/server.bundle.mjs +206 -157
  87. package/skills/ctx-index/SKILL.md +46 -0
  88. package/skills/ctx-search/SKILL.md +35 -0
  89. package/start.mjs +84 -11
  90. package/build/cache-heal.d.ts +0 -48
  91. package/build/cache-heal.js +0 -150
  92. package/build/concurrency/runPool.d.ts +0 -36
  93. package/build/concurrency/runPool.js +0 -51
  94. package/build/openclaw/mcp-tools.d.ts +0 -54
  95. package/build/openclaw/mcp-tools.js +0 -198
  96. package/build/openclaw/workspace-router.d.ts +0 -29
  97. package/build/openclaw/workspace-router.js +0 -64
  98. package/build/openclaw-plugin.d.ts +0 -130
  99. package/build/openclaw-plugin.js +0 -626
  100. package/build/opencode-plugin.d.ts +0 -122
  101. package/build/opencode-plugin.js +0 -375
  102. package/build/pi-extension.d.ts +0 -14
  103. package/build/pi-extension.js +0 -451
  104. package/build/routing-block.d.ts +0 -8
  105. package/build/routing-block.js +0 -86
  106. package/build/tool-naming.d.ts +0 -4
  107. package/build/tool-naming.js +0 -24
@@ -50,6 +50,14 @@ const SYSTEM_REMINDER_PREFIXES = [
50
50
  "<context_guidance>",
51
51
  "<tool-result>",
52
52
  ];
53
+ const SKILL_FRONTMATTER_REGEX = /^---\s*\n[\s\S]*?\n---\s*\n?/;
54
+ const OPENCLAW_SKILL_PROMPT_CHAR_LIMIT = 6000;
55
+ const SKILL_SECTION_HEADINGS = [
56
+ "MANDATORY RULE",
57
+ "Decision Tree",
58
+ "When to Use Each Tool",
59
+ "Critical Rules",
60
+ ];
53
61
  function isSystemReminderMessage(msg) {
54
62
  const trimmed = msg.trimStart();
55
63
  for (const prefix of SYSTEM_REMINDER_PREFIXES) {
@@ -58,6 +66,40 @@ function isSystemReminderMessage(msg) {
58
66
  }
59
67
  return false;
60
68
  }
69
+ function stripMarkdownFrontmatter(markdown) {
70
+ const normalized = markdown.replace(/\r\n/g, "\n");
71
+ return normalized.replace(SKILL_FRONTMATTER_REGEX, "").trim();
72
+ }
73
+ function extractMarkdownSection(markdown, heading) {
74
+ const sectionHeader = `## ${heading}`;
75
+ const start = markdown.indexOf(sectionHeader);
76
+ if (start === -1)
77
+ return null;
78
+ const afterHeader = markdown.slice(start + sectionHeader.length);
79
+ const nextHeadingOffset = afterHeader.search(/\n##\s+|\n#\s+/);
80
+ const body = (nextHeadingOffset === -1 ? afterHeader : afterHeader.slice(0, nextHeadingOffset)).trim();
81
+ if (!body)
82
+ return null;
83
+ return `${sectionHeader}\n\n${body}`;
84
+ }
85
+ function buildOpenClawSkillLikeGuidance(skillMarkdown) {
86
+ const skillBody = stripMarkdownFrontmatter(skillMarkdown);
87
+ const skillTitle = skillBody.match(/^#\s+.+$/m)?.[0]?.trim() ?? "# Context Mode Skill";
88
+ const selectedSections = SKILL_SECTION_HEADINGS
89
+ .map((heading) => extractMarkdownSection(skillBody, heading))
90
+ .filter((section) => Boolean(section));
91
+ const rawContent = (selectedSections.length > 0
92
+ ? [skillTitle, ...selectedSections].join("\n\n")
93
+ : skillBody).trim();
94
+ const boundedContent = rawContent.length > OPENCLAW_SKILL_PROMPT_CHAR_LIMIT
95
+ ? `${rawContent.slice(0, OPENCLAW_SKILL_PROMPT_CHAR_LIMIT).trimEnd()}\n\n[skill excerpt truncated for prompt budget]`
96
+ : rawContent;
97
+ return [
98
+ "<context_mode_skill_like_guidance source=\"skills/context-mode/SKILL.md\">",
99
+ boundedContent,
100
+ "</context_mode_skill_like_guidance>",
101
+ ].join("\n");
102
+ }
61
103
  /** Plugin config schema for OpenClaw validation. */
62
104
  const configSchema = {
63
105
  type: "object",
@@ -154,11 +196,34 @@ export default {
154
196
  // info/error always emit; debug only when api.logger.debug is present
155
197
  // (i.e. OpenClaw running with --log-level debug or lower).
156
198
  const log = {
157
- info: (...args) => api.logger?.info("[context-mode]", ...args),
158
- error: (...args) => api.logger?.error("[context-mode]", ...args),
199
+ info: (...args) => api.logger?.info?.("[context-mode]", ...args),
200
+ error: (...args) => api.logger?.error?.("[context-mode]", ...args),
159
201
  debug: (...args) => api.logger?.debug?.("[context-mode]", ...args),
160
202
  warn: (...args) => api.logger?.warn?.("[context-mode]", ...args),
161
203
  };
204
+ const registerCommandHook = (event, handler, meta) => {
205
+ if (api.registerHook) {
206
+ api.registerHook(event, handler, meta);
207
+ return;
208
+ }
209
+ try {
210
+ api.on(event, handler);
211
+ log.debug("command hook registered via api.on fallback", { event });
212
+ }
213
+ catch (err) {
214
+ log.warn?.("command hook registration skipped", { event }, err);
215
+ }
216
+ };
217
+ const registerAutoReplyCommand = (command) => {
218
+ if (!api.registerCommand)
219
+ return;
220
+ try {
221
+ api.registerCommand(command);
222
+ }
223
+ catch (err) {
224
+ log.warn?.("registerCommand failed; skipping auto-reply command", { name: command.name }, err);
225
+ }
226
+ };
162
227
  // Get shared DB singleton (lazy-init on first register() call)
163
228
  const db = getOrCreateDB(projectDir);
164
229
  // Start with temp UUID — session_start will assign the real ID + sessionKey
@@ -180,6 +245,7 @@ export default {
180
245
  // with createRoutingBlock(createToolNamer("openclaw")) so OpenClaw-specific
181
246
  // MCP-prefix substitution stays in lockstep with hooks/routing-block.mjs.
182
247
  let routingInstructions = "";
248
+ let skillLikeInstructions = "";
183
249
  const initPromise = (async () => {
184
250
  const routingPath = resolve(buildDir, "..", "..", "..", "hooks", "core", "routing.mjs");
185
251
  const routing = await import(pathToFileURL(routingPath).href);
@@ -207,6 +273,18 @@ export default {
207
273
  // best effort
208
274
  }
209
275
  }
276
+ try {
277
+ const skillPath = resolve(pluginRoot, "skills", "context-mode", "SKILL.md");
278
+ if (existsSync(skillPath)) {
279
+ skillLikeInstructions = buildOpenClawSkillLikeGuidance(readFileSync(skillPath, "utf-8"));
280
+ }
281
+ else {
282
+ log.debug("context-mode skill file missing; skipping skill-like prompt injection", { skillPath });
283
+ }
284
+ }
285
+ catch (err) {
286
+ log.warn?.("failed to build skill-like guidance from SKILL.md", err);
287
+ }
210
288
  return { routing };
211
289
  })();
212
290
  // ── 1. tool_call:before — Routing enforcement ──────────
@@ -307,7 +385,7 @@ export default {
307
385
  }
308
386
  });
309
387
  // ── 3. command:new — Session initialization ────────────
310
- api.registerHook("command:new", async () => {
388
+ registerCommandHook("command:new", async () => {
311
389
  try {
312
390
  log.debug("command:new", { sessionId: sessionId.slice(0, 8) });
313
391
  db.cleanupOldSessions(7);
@@ -320,7 +398,7 @@ export default {
320
398
  description: "Session initialization — cleans up old sessions on /new command",
321
399
  });
322
400
  // ── 3b. command:reset / command:stop — Session cleanup ────
323
- api.registerHook("command:reset", async () => {
401
+ registerCommandHook("command:reset", async () => {
324
402
  try {
325
403
  log.debug("command:reset", { sessionId: sessionId.slice(0, 8) });
326
404
  db.cleanupOldSessions(7);
@@ -332,7 +410,7 @@ export default {
332
410
  name: "context-mode.session-reset",
333
411
  description: "Session cleanup on /reset command",
334
412
  });
335
- api.registerHook("command:stop", async () => {
413
+ registerCommandHook("command:stop", async () => {
336
414
  try {
337
415
  log.debug("command:stop", { sessionId: sessionId.slice(0, 8), sessionKey });
338
416
  if (sessionKey) {
@@ -472,14 +550,23 @@ export default {
472
550
  // call-time, so the first prompt-build firing after dynamic-import resolution
473
551
  // sees the dynamic ROUTING_BLOCK XML (matching hooks/routing-block.mjs).
474
552
  api.on("before_prompt_build", () => {
475
- if (!routingInstructions)
553
+ if (!routingInstructions && !skillLikeInstructions)
476
554
  return undefined;
477
- log.debug("before_prompt_build[routing]", { hasInstructions: !!routingInstructions });
478
- // v1.0.107 — visible marker so OpenClaw users can verify the routing
479
- // block reached the model (Mickey-class verification path; mirrors
480
- // OpenCode + Pi adapters).
481
- const marker = `<!-- context-mode: routing block injected (sessionID=${String(sessionId).slice(0, 8)}) -->`;
482
- return { appendSystemContext: marker + "\n" + routingInstructions };
555
+ log.debug("before_prompt_build[routing+skill]", {
556
+ hasRoutingInstructions: !!routingInstructions,
557
+ hasSkillLikeInstructions: !!skillLikeInstructions,
558
+ });
559
+ const injectedBlocks = [];
560
+ if (routingInstructions) {
561
+ // v1.0.107 — visible marker so OpenClaw users can verify the routing
562
+ // block reached the model (Mickey-class verification path; mirrors
563
+ // OpenCode + Pi adapters).
564
+ const marker = `<!-- context-mode: routing block injected (sessionID=${String(sessionId).slice(0, 8)}) -->`;
565
+ injectedBlocks.push(marker + "\n" + routingInstructions);
566
+ }
567
+ if (skillLikeInstructions)
568
+ injectedBlocks.push(skillLikeInstructions);
569
+ return { appendSystemContext: injectedBlocks.join("\n\n") };
483
570
  }, { priority: 5 });
484
571
  // ── 8b. registerTool — Expose 11 ctx_* tools (SLICE OClaw-1) ────
485
572
  // Phase 7 audit (v1.0.107-adapter-openclaw.json) flagged severity=CRITICAL:
@@ -531,14 +618,22 @@ export default {
531
618
  try {
532
619
  const e = (event ?? {});
533
620
  const basePrompt = e?.input?.prompt ?? "";
534
- if (!routingInstructions)
621
+ if (!routingInstructions && !skillLikeInstructions)
535
622
  return undefined;
623
+ const injectedBlocks = [];
624
+ if (routingInstructions)
625
+ injectedBlocks.push(routingInstructions);
626
+ if (skillLikeInstructions)
627
+ injectedBlocks.push(skillLikeInstructions);
628
+ const injectedPromptBlock = injectedBlocks.join("\n\n");
536
629
  const newPrompt = basePrompt
537
- ? `${basePrompt}\n\n${routingInstructions}`
538
- : routingInstructions;
539
- log.debug("subagent_spawning[inject-routing]", {
630
+ ? `${basePrompt}\n\n${injectedPromptBlock}`
631
+ : injectedPromptBlock;
632
+ log.debug("subagent_spawning[inject-routing+skill]", {
540
633
  basePromptLen: basePrompt.length,
541
- blockLen: routingInstructions.length,
634
+ hasRoutingInstructions: !!routingInstructions,
635
+ hasSkillLikeInstructions: !!skillLikeInstructions,
636
+ blockLen: injectedPromptBlock.length,
542
637
  });
543
638
  return { inputOverride: { ...(e.input ?? {}), prompt: newPrompt } };
544
639
  }
@@ -547,26 +642,31 @@ export default {
547
642
  }
548
643
  });
549
644
  // ── 9. Context engine — Compaction management ──────────
550
- api.registerContextEngine("context-mode", () => ({
551
- info: {
552
- id: "context-mode",
553
- name: "Context Mode",
554
- ownsCompaction: false,
555
- },
556
- async ingest() {
557
- return { ingested: true };
558
- },
559
- async assemble({ messages }) {
560
- return { messages, estimatedTokens: 0 };
561
- },
562
- async compact() {
563
- // No-op: session continuity is handled by before_compaction / after_compaction hooks.
564
- // Returning ownsCompaction: false + compacted: false lets the host platform (OpenClaw)
565
- // manage conversation truncation, preserving Anthropic thinking/redacted_thinking blocks.
566
- // See: https://github.com/mksglu/context-mode/issues/191
567
- return { ok: true, compacted: false };
568
- },
569
- }));
645
+ if (api.registerContextEngine) {
646
+ api.registerContextEngine("context-mode", () => ({
647
+ info: {
648
+ id: "context-mode",
649
+ name: "Context Mode",
650
+ ownsCompaction: false,
651
+ },
652
+ async ingest() {
653
+ return { ingested: true };
654
+ },
655
+ async assemble({ messages }) {
656
+ return { messages, estimatedTokens: 0 };
657
+ },
658
+ async compact() {
659
+ // No-op: session continuity is handled by before_compaction / after_compaction hooks.
660
+ // Returning ownsCompaction: false + compacted: false lets the host platform (OpenClaw)
661
+ // manage conversation truncation, preserving Anthropic thinking/redacted_thinking blocks.
662
+ // See: https://github.com/mksglu/context-mode/issues/191
663
+ return { ok: true, compacted: false };
664
+ },
665
+ }));
666
+ }
667
+ else {
668
+ log.warn?.("api.registerContextEngine unavailable — skipping context engine registration");
669
+ }
570
670
  // ── 10. Auto-reply commands — ctx slash commands ──────
571
671
  // Update module-level refs so command handlers (registered once) always
572
672
  // read the latest session's db/sessionId/pluginRoot.
@@ -574,7 +674,7 @@ export default {
574
674
  _latestSessionId = sessionId;
575
675
  _latestPluginRoot = pluginRoot;
576
676
  if (api.registerCommand) {
577
- api.registerCommand({
677
+ registerAutoReplyCommand({
578
678
  name: "ctx-stats",
579
679
  description: "Show context-mode session statistics",
580
680
  handler: () => {
@@ -582,7 +682,7 @@ export default {
582
682
  return { text };
583
683
  },
584
684
  });
585
- api.registerCommand({
685
+ registerAutoReplyCommand({
586
686
  name: "ctx-doctor",
587
687
  description: "Run context-mode diagnostics",
588
688
  handler: () => {
@@ -603,7 +703,7 @@ export default {
603
703
  };
604
704
  },
605
705
  });
606
- api.registerCommand({
706
+ registerAutoReplyCommand({
607
707
  name: "ctx-upgrade",
608
708
  description: "Upgrade context-mode to the latest version",
609
709
  handler: () => {
@@ -246,9 +246,10 @@ async function createContextModePlugin(ctx) {
246
246
  : typeof inputSchema?._def?.shape === "function"
247
247
  ? inputSchema._def.shape()
248
248
  : {};
249
- const argsForHost = platform === "kilo"
250
- ? zod3ShapeToV4(shape)
251
- : shape;
249
+ // Both KiloCode and recent OpenCode bundle Zod v4 in-host; v3 schemas
250
+ // crash with `n._zod.def` undefined. Gate widened from kilo-only (#632)
251
+ // because every consumer of this file is an OpenCode-family host.
252
+ const argsForHost = zod3ShapeToV4(shape);
252
253
  tools[registered.name] = {
253
254
  description: String(config.description ?? ""),
254
255
  args: argsForHost,
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Zod 3 → Zod 4 shape conversion (KiloCode only).
2
+ * Zod 3 → Zod 4 shape conversion for in-process plugin hosts that bundle Zod v4.
3
3
  *
4
- * KiloCode's runtime bundles Zod v4 internally. When it receives plugin tool
5
- * definitions whose `args` contain Zod v3 schemas (with `_def` but no `_zod`),
6
- * it crashes with `undefined is not an object (evaluating 'n._zod.def')`.
4
+ * KiloCode (since its inception) and recent OpenCode releases (≥ ~1.14.x)
5
+ * bundle Zod v4 internally. When they receive plugin tool definitions whose
6
+ * `args` contain Zod v3 schemas (with `_def` but no `_zod.def`), they crash
7
+ * with `TypeError: undefined is not an object (evaluating 'n._zod.def')`.
7
8
  *
8
- * This module converts Zod 3 schema shapes into Zod 4 equivalents so KiloCode
9
- * can process them natively. Only called when `platform === "kilo"`.
10
- * OpenCode uses Zod 3 natively and receives the original shapes unchanged.
9
+ * This module converts Zod 3 schema shapes into Zod 4 equivalents so both
10
+ * hosts can process them natively.
11
11
  */
12
12
  import z from 'zod/v4';
13
13
  export function zod3ShapeToV4(shape, depth = 0) {
@@ -101,7 +101,7 @@ function zod3ToV4(v, depth = 0) {
101
101
  result = zod3ToV4(def.schema, depth + 1);
102
102
  break;
103
103
  default:
104
- // Never leak raw Zod 3 schemas back to KiloCode.
104
+ // Never leak raw Zod 3 schemas back to a v4 host.
105
105
  result = z.unknown();
106
106
  break;
107
107
  }
@@ -141,22 +141,6 @@ let _mcpBridge = null;
141
141
  * a prior load.
142
142
  */
143
143
  export let _mcpBridgeReady = Promise.resolve();
144
- // Cached routing-block string (built once per process from hooks/routing-block.mjs).
145
- let _routingBlock = null;
146
- async function getRoutingBlock(pluginRoot) {
147
- if (_routingBlock !== null)
148
- return _routingBlock;
149
- try {
150
- const routingMod = await import(pathToFileURL(join(pluginRoot, "hooks", "routing-block.mjs")).href);
151
- const namingMod = await import(pathToFileURL(join(pluginRoot, "hooks", "core", "tool-naming.mjs")).href);
152
- const t = namingMod.createToolNamer("pi");
153
- _routingBlock = String(routingMod.createRoutingBlock(t));
154
- }
155
- catch {
156
- _routingBlock = "";
157
- }
158
- return _routingBlock;
159
- }
160
144
  // Cached buildAutoInjection (500-token cap, prioritized).
161
145
  let _buildAutoInjection = undefined;
162
146
  async function getAutoInjection(pluginRoot) {
@@ -546,14 +530,15 @@ export default function piExtension(pi) {
546
530
  const parts = [];
547
531
  if (existingPrompt)
548
532
  parts.push(existingPrompt);
549
- // Pi-1: Inject routing block every turn.
550
- // Unlike Claude Code where the SessionStart hook injects once into a persistent
551
- // context, Pi rebuilds the system prompt fresh on every before_agent_start call.
552
- // The routing block must be re-injected each turn or it disappears after turn 1.
553
- const routingBlock = await getRoutingBlock(pluginRoot);
554
- if (routingBlock) {
555
- parts.push(routingBlock);
556
- }
533
+ // Pi-1: Lightweight routing anchor — 7KB routing block is too heavy
534
+ // for Pi's context budget. Tool descriptions from pi.registerTool()
535
+ // already tell the model what each tool does. This anchor gives the
536
+ // deliberate choice (which tool for which scenario) without the full
537
+ // block/redirect/memory/tool-selection hierarchy.
538
+ parts.push("context-mode active. Hierarchy: ctx_batch_execute > ctx_execute > ctx_execute_file > ctx_search. " +
539
+ "Read/edit files → ctx_execute_file. Multi-command research → ctx_batch_execute. " +
540
+ "Web pages → ctx_fetch_and_index then ctx_search. Index docs → ctx_index. " +
541
+ "Stats → ctx_stats. Doctor → ctx_doctor. Upgrade → ctx_upgrade. Purge → ctx_purge.");
557
542
  // Pi-3 + Pi-4: Always build active_memory (not just post-compact),
558
543
  // capped at 500 tokens via buildAutoInjection. Falls back to inline
559
544
  // budget loop if the helper is unavailable.
@@ -20,6 +20,8 @@
20
20
  *
21
21
  * No external dependencies — pure node:child_process + JSON line frames.
22
22
  */
23
+ import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
23
25
  import { spawn, execSync } from "node:child_process";
24
26
  import { detectRuntimes } from "../../runtime.js";
25
27
  import { foreignWorkspaceEnv, foreignIdentificationEnv } from "../detect.js";
@@ -393,6 +395,41 @@ export class MCPStdioClient {
393
395
  for (const banned of foreignIdentificationEnv("pi")) {
394
396
  delete childEnv[banned];
395
397
  }
398
+ // Issue #561 regression fix: Pi detection vars are empty after
399
+ // foreign env scrubbing (CLAUDE_CODE_ENTRYPOINT / CLAUDE_PLUGIN_ROOT
400
+ // are deleted by the ban above). Without PI_CONFIG_DIR,
401
+ // detectPlatform() finds zero Pi identification vars and falls
402
+ // through to Claude Code default — stats land in ~/.claude/ instead
403
+ // of ~/.pi/. Set PI_CONFIG_DIR from the child's HOME env var so the
404
+ // child resolves to Pi correctly. (Use childEnv.HOME, not homedir(),
405
+ // because homedir() reads getpwent() which ignores our HOME override
406
+ // in test environments.)
407
+ //
408
+ // Cross-OS PI_CONFIG_DIR rescue (PR #741 follow-up):
409
+ // 1. If the parent already exported PI_CONFIG_DIR, trust it
410
+ // verbatim — Pi's launcher owns that path and may pin it to
411
+ // a non-default location (corporate setup, CI, etc.).
412
+ // 2. POSIX: ~/.pi (HOME-rooted).
413
+ // 3. Windows: probe both %USERPROFILE%\.pi (rare native install)
414
+ // AND %APPDATA%\.pi (XDG-on-Windows, Pi's documented Windows
415
+ // layout). Without the APPDATA fallback, every Pi-on-Windows
416
+ // install silently drops back to the Claude Code default and
417
+ // Pi's sessions write into the wrong directory.
418
+ if (!childEnv.PI_CONFIG_DIR) {
419
+ const home = childEnv.HOME ?? childEnv.USERPROFILE ?? childEnv.HOMEPATH;
420
+ const appData = childEnv.APPDATA; // Windows-only, undefined on POSIX
421
+ const candidates = [];
422
+ if (home)
423
+ candidates.push(join(home, ".pi"));
424
+ if (appData)
425
+ candidates.push(join(appData, ".pi"));
426
+ for (const candidate of candidates) {
427
+ if (existsSync(candidate)) {
428
+ childEnv.PI_CONFIG_DIR = candidate;
429
+ break;
430
+ }
431
+ }
432
+ }
396
433
  this._spawnEnv = childEnv;
397
434
  this.child = spawn(runtime, [this.serverScript], {
398
435
  // Pipe stderr (#472 round-3): swallowing it via "ignore" hides
@@ -17,7 +17,7 @@ import { resolve, join } from "node:path";
17
17
  import { homedir } from "node:os";
18
18
  import { ClaudeCodeBaseAdapter } from "../claude-code-base.js";
19
19
  import { EXTERNAL_MCP_MATCHER_PATTERN } from "./hooks.js";
20
- import { buildNodeCommand, } from "../types.js";
20
+ import { buildHookRuntimeCommand, } from "../types.js";
21
21
  // ─────────────────────────────────────────────────────────
22
22
  // Adapter implementation
23
23
  // ─────────────────────────────────────────────────────────
@@ -65,7 +65,7 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
65
65
  {
66
66
  matcher: preToolUseMatcher,
67
67
  hooks: [
68
- { type: "command", command: buildNodeCommand(`${pluginRoot}/hooks/pretooluse.mjs`) },
68
+ { type: "command", command: buildHookRuntimeCommand(`${pluginRoot}/hooks/pretooluse.mjs`) },
69
69
  ],
70
70
  },
71
71
  ],
@@ -73,7 +73,7 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
73
73
  {
74
74
  matcher: "run_shell_command|read_file|write_file|edit|glob|grep_search|todo_write|agent|ask_user_question|mcp__",
75
75
  hooks: [
76
- { type: "command", command: buildNodeCommand(`${pluginRoot}/hooks/posttooluse.mjs`) },
76
+ { type: "command", command: buildHookRuntimeCommand(`${pluginRoot}/hooks/posttooluse.mjs`) },
77
77
  ],
78
78
  },
79
79
  ],
@@ -81,7 +81,7 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
81
81
  {
82
82
  matcher: "",
83
83
  hooks: [
84
- { type: "command", command: buildNodeCommand(`${pluginRoot}/hooks/sessionstart.mjs`) },
84
+ { type: "command", command: buildHookRuntimeCommand(`${pluginRoot}/hooks/sessionstart.mjs`) },
85
85
  ],
86
86
  },
87
87
  ],
@@ -89,7 +89,7 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
89
89
  {
90
90
  matcher: "",
91
91
  hooks: [
92
- { type: "command", command: buildNodeCommand(`${pluginRoot}/hooks/precompact.mjs`) },
92
+ { type: "command", command: buildHookRuntimeCommand(`${pluginRoot}/hooks/precompact.mjs`) },
93
93
  ],
94
94
  },
95
95
  ],
@@ -97,7 +97,7 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
97
97
  {
98
98
  matcher: "",
99
99
  hooks: [
100
- { type: "command", command: buildNodeCommand(`${pluginRoot}/hooks/userpromptsubmit.mjs`) },
100
+ { type: "command", command: buildHookRuntimeCommand(`${pluginRoot}/hooks/userpromptsubmit.mjs`) },
101
101
  ],
102
102
  },
103
103
  ],
@@ -279,7 +279,7 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
279
279
  for (const { name, script, matcher } of hookTypes) {
280
280
  const entry = {
281
281
  matcher,
282
- hooks: [{ type: "command", command: buildNodeCommand(`${pluginRoot}/hooks/${script}`) }],
282
+ hooks: [{ type: "command", command: buildHookRuntimeCommand(`${pluginRoot}/hooks/${script}`) }],
283
283
  };
284
284
  const existing = hooks[name];
285
285
  if (existing && Array.isArray(existing)) {
@@ -286,7 +286,39 @@ export interface HealthCheck {
286
286
  *
287
287
  * Safe on macOS/Linux — quoting and forward slashes are no-ops there.
288
288
  */
289
- export declare function buildNodeCommand(scriptPath: string): string;
289
+ export declare function buildNodeCommand(scriptPath: string, opts?: {
290
+ platform?: string;
291
+ jsRuntime?: string;
292
+ }): string;
293
+ /**
294
+ * Build a cross-platform hook spawn command using the resolved JS runtime
295
+ * (issue #738). Identical wire-format to {@link buildNodeCommand} — two
296
+ * double-quoted, forward-slashed tokens separated by whitespace — so it
297
+ * round-trips through {@link parseNodeCommand} unchanged.
298
+ *
299
+ * The only difference is the runtime path: when a Bun ≥1.0 install is
300
+ * detected at process start, that path is used in place of `process.execPath`.
301
+ * Hooks run end-to-end in pure JS (no native modules) so swapping the
302
+ * runtime is a no-op for output but cuts ~40-60ms of Node cold-start per
303
+ * tool call.
304
+ *
305
+ * Why a SEPARATE helper instead of repurposing {@link buildNodeCommand}:
306
+ * `buildNodeCommand` is also called by openclaw plugin (doctor / upgrade
307
+ * command suggestions in `src/adapters/openclaw/plugin.ts`). Those CLI
308
+ * targets MUST stay on Node because they load better-sqlite3, which has
309
+ * no Bun-compatible prebuild yet (#543). Keeping the two helpers separate
310
+ * makes the audit trivial: anything emitting a hook spawn command uses
311
+ * `buildHookRuntimeCommand`; anything emitting a user-visible CLI command
312
+ * stays on `buildNodeCommand`.
313
+ *
314
+ * `opts.platform` is forwarded to {@link isInProcessPluginPlatform} so the
315
+ * existing opencode/kilo in-process JS-runtime substitution still works
316
+ * (those platforms inject their own runtime via `opts.jsRuntime`).
317
+ */
318
+ export declare function buildHookRuntimeCommand(scriptPath: string, opts?: {
319
+ platform?: string;
320
+ jsRuntime?: string;
321
+ }): string;
290
322
  /**
291
323
  * Strict inverse of `buildNodeCommand`.
292
324
  *
@@ -309,8 +341,13 @@ export declare function parseNodeCommand(cmd: string): {
309
341
  nodePath: string;
310
342
  scriptPath: string;
311
343
  } | null;
344
+ /** Known JS runtime binary names (base filename without extension). */
345
+ export declare const JS_RUNTIMES: ReadonlySet<string>;
346
+ /** Platforms where context-mode runs as an in-process TS plugin (not MCP stdio). */
347
+ export declare const IN_PROCESS_PLUGIN_PLATFORMS: ReadonlySet<string>;
348
+ export declare function isInProcessPluginPlatform(p: string | undefined): boolean;
312
349
  /** Supported platform identifiers. */
313
- export type PlatformId = "claude-code" | "gemini-cli" | "opencode" | "kilo" | "openclaw" | "codex" | "vscode-copilot" | "jetbrains-copilot" | "cursor" | "antigravity" | "kiro" | "pi" | "omp" | "zed" | "qwen-code" | "unknown";
350
+ export type PlatformId = "claude-code" | "gemini-cli" | "opencode" | "kilo" | "openclaw" | "codex" | "vscode-copilot" | "jetbrains-copilot" | "cursor" | "antigravity" | "kiro" | "pi" | "omp" | "kimi" | "zed" | "qwen-code" | "unknown";
314
351
  /** Detection signal used to identify which platform is running. */
315
352
  export interface DetectionSignal {
316
353
  /** Platform identifier. */
@@ -11,6 +11,10 @@
11
11
  * Only the hook layer requires platform-specific adapters.
12
12
  */
13
13
  // ─────────────────────────────────────────────────────────
14
+ // Hook paradigm
15
+ // ─────────────────────────────────────────────────────────
16
+ import { resolveHookRuntime } from "../runtime.js";
17
+ // ─────────────────────────────────────────────────────────
14
18
  // Platform detection
15
19
  // ─────────────────────────────────────────────────────────
16
20
  // ─────────────────────────────────────────────────────────
@@ -28,11 +32,53 @@
28
32
  *
29
33
  * Safe on macOS/Linux — quoting and forward slashes are no-ops there.
30
34
  */
31
- export function buildNodeCommand(scriptPath) {
32
- const nodePath = process.execPath.replace(/\\/g, "/");
35
+ export function buildNodeCommand(scriptPath, opts) {
36
+ let nodePath = process.execPath.replace(/\\/g, "/");
37
+ if (isInProcessPluginPlatform(opts?.platform)) {
38
+ const base = nodePath.split("/").pop().replace(/\.exe$/i, "");
39
+ if (!JS_RUNTIMES.has(base)) {
40
+ nodePath = opts?.jsRuntime?.replace(/\\/g, "/") ?? "node";
41
+ }
42
+ }
33
43
  const safePath = scriptPath.replace(/\\/g, "/");
34
44
  return `"${nodePath}" "${safePath}"`;
35
45
  }
46
+ /**
47
+ * Build a cross-platform hook spawn command using the resolved JS runtime
48
+ * (issue #738). Identical wire-format to {@link buildNodeCommand} — two
49
+ * double-quoted, forward-slashed tokens separated by whitespace — so it
50
+ * round-trips through {@link parseNodeCommand} unchanged.
51
+ *
52
+ * The only difference is the runtime path: when a Bun ≥1.0 install is
53
+ * detected at process start, that path is used in place of `process.execPath`.
54
+ * Hooks run end-to-end in pure JS (no native modules) so swapping the
55
+ * runtime is a no-op for output but cuts ~40-60ms of Node cold-start per
56
+ * tool call.
57
+ *
58
+ * Why a SEPARATE helper instead of repurposing {@link buildNodeCommand}:
59
+ * `buildNodeCommand` is also called by openclaw plugin (doctor / upgrade
60
+ * command suggestions in `src/adapters/openclaw/plugin.ts`). Those CLI
61
+ * targets MUST stay on Node because they load better-sqlite3, which has
62
+ * no Bun-compatible prebuild yet (#543). Keeping the two helpers separate
63
+ * makes the audit trivial: anything emitting a hook spawn command uses
64
+ * `buildHookRuntimeCommand`; anything emitting a user-visible CLI command
65
+ * stays on `buildNodeCommand`.
66
+ *
67
+ * `opts.platform` is forwarded to {@link isInProcessPluginPlatform} so the
68
+ * existing opencode/kilo in-process JS-runtime substitution still works
69
+ * (those platforms inject their own runtime via `opts.jsRuntime`).
70
+ */
71
+ export function buildHookRuntimeCommand(scriptPath, opts) {
72
+ // In-process plugin platforms (opencode/kilo) inject their own runtime —
73
+ // delegate to buildNodeCommand which already handles that special case.
74
+ if (isInProcessPluginPlatform(opts?.platform)) {
75
+ return buildNodeCommand(scriptPath, opts);
76
+ }
77
+ const runtime = resolveHookRuntime();
78
+ const runtimePath = runtime.path.replace(/\\/g, "/");
79
+ const safePath = scriptPath.replace(/\\/g, "/");
80
+ return `"${runtimePath}" "${safePath}"`;
81
+ }
36
82
  /**
37
83
  * Strict inverse of `buildNodeCommand`.
38
84
  *
@@ -62,3 +108,10 @@ export function parseNodeCommand(cmd) {
62
108
  return null;
63
109
  return { nodePath: m[1], scriptPath: m[2] };
64
110
  }
111
+ /** Known JS runtime binary names (base filename without extension). */
112
+ export const JS_RUNTIMES = new Set(["node", "bun", "deno"]);
113
+ /** Platforms where context-mode runs as an in-process TS plugin (not MCP stdio). */
114
+ export const IN_PROCESS_PLUGIN_PLATFORMS = new Set(["opencode", "kilo"]);
115
+ export function isInProcessPluginPlatform(p) {
116
+ return !!p && IN_PROCESS_PLUGIN_PLATFORMS.has(p);
117
+ }
@@ -43,7 +43,19 @@ export class VSCodeCopilotAdapter extends CopilotBaseAdapter {
43
43
  return `pid-${process.ppid}`;
44
44
  }
45
45
  getProjectDir() {
46
- return process.env.CLAUDE_PROJECT_DIR || process.cwd();
46
+ // Cascade order (locked by tests/adapters/vscode-copilot.test.ts):
47
+ // 1. CLAUDE_PROJECT_DIR — top priority for users running VS Code under
48
+ // Claude Code CLI.
49
+ // 2. VSCODE_CWD — exported by VS Code's bootstrap into every child it
50
+ // spawns (refs/platforms/vscode-copilot/src/util/vs/base/common/
51
+ // process.ts:31). The MCP child inherits it. Was previously missing
52
+ // from this cascade — every direct VS Code Copilot session silently
53
+ // lost its workspace folder. PR #689 5-agent EM audit (Phase A
54
+ // claim verification) confirmed the gap; this is the minimal fix.
55
+ // 3. process.cwd() — last resort.
56
+ return (process.env.CLAUDE_PROJECT_DIR
57
+ || process.env.VSCODE_CWD
58
+ || process.cwd());
47
59
  }
48
60
  getSessionDir() {
49
61
  // Issue #649: CONTEXT_MODE_DATA_DIR wins over both the .github project