context-mode 1.0.136 → 1.0.138

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 (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +9 -24
  7. package/build/adapters/codex/index.js +24 -3
  8. package/build/adapters/jetbrains-copilot/hooks.d.ts +11 -3
  9. package/build/adapters/jetbrains-copilot/hooks.js +11 -7
  10. package/build/adapters/opencode/index.d.ts +1 -0
  11. package/build/adapters/opencode/index.js +25 -0
  12. package/build/adapters/opencode/plugin.d.ts +22 -0
  13. package/build/adapters/opencode/plugin.js +52 -0
  14. package/build/adapters/pi/extension.js +20 -4
  15. package/build/adapters/pi/mcp-bridge.d.ts +2 -1
  16. package/build/adapters/pi/mcp-bridge.js +49 -3
  17. package/build/adapters/vscode-copilot/hooks.d.ts +27 -3
  18. package/build/adapters/vscode-copilot/hooks.js +27 -12
  19. package/build/cli.js +199 -32
  20. package/build/lifecycle.d.ts +2 -51
  21. package/build/lifecycle.js +3 -67
  22. package/build/openclaw-plugin.d.ts +130 -0
  23. package/build/openclaw-plugin.js +626 -0
  24. package/build/opencode-plugin.d.ts +122 -0
  25. package/build/opencode-plugin.js +372 -0
  26. package/build/pi-extension.d.ts +14 -0
  27. package/build/pi-extension.js +451 -0
  28. package/build/server.d.ts +19 -0
  29. package/build/server.js +145 -59
  30. package/build/session/db.d.ts +6 -0
  31. package/build/session/db.js +17 -3
  32. package/build/util/db-lock.d.ts +65 -0
  33. package/build/util/db-lock.js +166 -0
  34. package/build/util/sibling-mcp.d.ts +0 -40
  35. package/build/util/sibling-mcp.js +11 -116
  36. package/cli.bundle.mjs +181 -166
  37. package/configs/kilo/kilo.json +0 -11
  38. package/configs/opencode/opencode.json +0 -11
  39. package/hooks/normalize-hooks.mjs +101 -19
  40. package/hooks/session-db.bundle.mjs +3 -3
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +1 -1
  43. package/scripts/heal-installed-plugins.mjs +115 -1
  44. package/scripts/postinstall.mjs +16 -18
  45. package/server.bundle.mjs +112 -110
  46. package/start.mjs +11 -14
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.136"
9
+ "version": "1.0.138"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.136",
16
+ "version": "1.0.138",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.136",
3
+ "version": "1.0.138",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -27,5 +27,5 @@
27
27
  ]
28
28
  }
29
29
  },
30
- "skills": "./skills/"
30
+ "skills": "./.claude/skills/"
31
31
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.136",
3
+ "version": "1.0.138",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.136",
6
+ "version": "1.0.138",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.136",
3
+ "version": "1.0.138",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
package/README.md CHANGED
@@ -421,20 +421,11 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
421
421
  ```json
422
422
  {
423
423
  "$schema": "https://opencode.ai/config.json",
424
- "mcp": {
425
- "context-mode": {
426
- "type": "local",
427
- "command": ["context-mode"],
428
- "environment": {
429
- "CONTEXT_MODE_IDLE_TIMEOUT_MS": "900000"
430
- }
431
- }
432
- },
433
424
  "plugin": ["context-mode"]
434
425
  }
435
426
  ```
436
427
 
437
- The `mcp` entry registers all 11 MCP tools. The `plugin` entry enables hooks — OpenCode calls the plugin's TypeScript functions directly before and after each tool execution, blocking dangerous commands and enforcing sandbox routing.
428
+ The `plugin` entry registers all 11 `ctx_*` tools natively and enables hooks — OpenCode calls context-mode's TypeScript plugin in-process, so there is no redundant stdio MCP child per session.
438
429
 
439
430
  3. *(Optional)* Copy the routing rules file. The model needs an `AGENTS.md` file for routing awareness:
440
431
 
@@ -448,6 +439,8 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
448
439
 
449
440
  **Verify:** In the OpenCode session, type `ctx stats`. Context-mode tools should appear and respond.
450
441
 
442
+ **Upgrade note:** If an existing config still has `mcp.context-mode`, run `context-mode upgrade`. OpenCode now gets `ctx_*` tools from the plugin; the upgrade removes only `mcp.context-mode` and preserves any other MCP servers.
443
+
451
444
  **Routing:** Hooks enforce routing programmatically via `tool.execute.before` and `tool.execute.after`. The optional [`AGENTS.md`](configs/opencode/AGENTS.md) file provides routing instructions for model awareness. The `experimental.session.compacting` hook builds resume snapshots when the conversation compacts. The `experimental.chat.system.transform` hook injects the routing block and prior-session snapshots at session start, enabling session continuity across restarts. The `chat.message` hook captures user prompts and decisions (UserPromptSubmit equivalent).
452
445
 
453
446
  > **Note:** OpenCode lacks a real SessionStart hook ([#14808](https://github.com/sst/opencode/issues/14808), [#5409](https://github.com/sst/opencode/issues/5409)). The plugin uses `experimental.chat.system.transform` as a surrogate — it injects both the routing block and resume snapshots into the system prompt. User-prompt capture uses `chat.message` instead of the missing UserPromptSubmit hook. AGENTS.md/CLAUDE.md/CONTEXT.md rules are captured automatically on first hook fire per project.
@@ -474,20 +467,11 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
474
467
  ```json
475
468
  {
476
469
  "$schema": "https://app.kilo.ai/config.json",
477
- "mcp": {
478
- "context-mode": {
479
- "type": "local",
480
- "command": ["context-mode"],
481
- "environment": {
482
- "CONTEXT_MODE_IDLE_TIMEOUT_MS": "900000"
483
- }
484
- }
485
- },
486
470
  "plugin": ["context-mode"]
487
471
  }
488
472
  ```
489
473
 
490
- The `mcp` entry registers all 11 MCP tools. The `plugin` entry enables hooks — KiloCode calls the plugin's TypeScript functions directly before and after each tool execution, blocking dangerous commands and enforcing sandbox routing.
474
+ The `plugin` entry registers all 11 `ctx_*` tools natively and enables hooks — KiloCode calls context-mode's TypeScript plugin in-process, so there is no redundant stdio MCP child per session.
491
475
 
492
476
  3. *(Optional)* Copy the routing rules file. KiloCode shares the OpenCode plugin architecture, so the model needs an `AGENTS.md` file for routing awareness:
493
477
 
@@ -499,6 +483,8 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
499
483
 
500
484
  **Verify:** In the KiloCode session, type `ctx stats`. Context-mode tools should appear and respond.
501
485
 
486
+ **Upgrade note:** If an existing config still has `mcp.context-mode`, run `context-mode upgrade`. KiloCode now gets `ctx_*` tools from the plugin; the upgrade removes only `mcp.context-mode` and preserves any other MCP servers.
487
+
502
488
  **Routing:** Hooks enforce routing programmatically via `tool.execute.before` and `tool.execute.after`. The optional [`AGENTS.md`](configs/opencode/AGENTS.md) file provides routing instructions for model awareness. The `experimental.session.compacting` hook builds resume snapshots when the conversation compacts. The `experimental.chat.system.transform` hook injects the routing block and prior-session snapshots at session start, enabling session continuity across restarts. The `chat.message` hook captures user prompts and decisions (UserPromptSubmit equivalent).
503
489
 
504
490
  > **Note:** KiloCode shares the same plugin architecture as OpenCode, using the OpenCodeAdapter with platform-specific configuration paths (`kilo.json` instead of `opencode.json`, `~/.config/kilo/` instead of `~/.config/opencode/`). Like OpenCode, it lacks a real SessionStart hook — the plugin uses `experimental.chat.system.transform` as a surrogate. User-prompt capture uses `chat.message` instead of the missing UserPromptSubmit hook. AGENTS.md/CLAUDE.md/CONTEXT.md rules are captured automatically on first hook fire per project.
@@ -1208,7 +1194,7 @@ Tool call output can be collapsed/expanded with the default Pi's default keybind
1208
1194
 
1209
1195
  | Feature | Claude Code | Qwen Code | Gemini CLI | VS Code Copilot | JetBrains Copilot | Cursor | OpenCode | KiloCode | OpenClaw | Codex CLI | Antigravity | Kiro | Zed | Pi | OMP |
1210
1196
  |---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
1211
- | MCP Server | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
1197
+ | MCP Server / Native Tools | Yes | Yes | Yes | Yes | Yes | Yes | Native plugin | Native plugin | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
1212
1198
  | PreToolUse Hook | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | Yes (extension) | Plugin |
1213
1199
  | PostToolUse Hook | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | Yes (extension) | Plugin |
1214
1200
  | SessionStart Hook | Yes | Yes | Yes | Yes | Yes | -- | ✓ (via experimental.chat.system.transform) | ✓ (via experimental.chat.system.transform) | Plugin | Yes | -- | -- | -- | Yes (extension) | Plugin |
@@ -1405,14 +1391,13 @@ That blocks loopback + RFC1918 + ULA in addition to the always-blocked ranges. U
1405
1391
 
1406
1392
  ### Lifecycle environment variables
1407
1393
 
1408
- Two runtime knobs control how MCP server processes self-manage. Defaults are conservative after [#592](https://github.com/mksglu/context-mode/issues/592): idle self-shutdown is disabled unless a host config explicitly opts in. OpenCode and KiloCode opt in because they open one MCP child per session/subagent; Claude Code/Codex/editor hosts keep registered tool handles after a clean MCP exit and therefore must not idle-exit by default.
1394
+ One runtime knob controls MCP sibling cleanup. Idle self-shutdown was removed after [#592](https://github.com/mksglu/context-mode/issues/592): hosts can keep registered tool handles after a clean MCP exit, making a timer-driven exit unsafe.
1409
1395
 
1410
1396
  | Variable | Default | Purpose |
1411
1397
  |---|---|---|
1412
- | `CONTEXT_MODE_IDLE_TIMEOUT_MS` | `0` (disabled) | When set to a positive integer, an MCP child self-exits cleanly after this many milliseconds of stdin/request inactivity. OpenCode and KiloCode configs set `900000` (15 min) because those hosts can accumulate one MCP child per session/subagent. Leave disabled for hosts that do not auto-respawn after MCP EOF (Claude Code, Codex, editor MCP clients) or ctx_* tools may go stale after idle. |
1413
1398
  | `CONTEXT_MODE_STARTUP_SWEEP` | `1` (enabled) | At boot, a newly-spawned MCP child reaps any other context-mode MCP server pids that share its parent process (`sameParentOnly: true` — never touches MCP children of a different host). This reclaims accumulated siblings immediately instead of waiting for each idle timer to fire. Set to `0` or `false` to disable (useful when you intentionally want multiple concurrent MCP children under the same host, e.g. multi-tenant test runners). |
1414
1399
 
1415
- Both vars are read fresh at MCP server start — no restart of the host CLI is required, just spawn a new MCP child (open a new session) for changes to take effect. Invalid/non-numeric `CONTEXT_MODE_IDLE_TIMEOUT_MS` values fall back to `0` (disabled); unrecognized `CONTEXT_MODE_STARTUP_SWEEP` values fall back to enabled.
1400
+ `CONTEXT_MODE_STARTUP_SWEEP` is read fresh at MCP server start — no restart of the host CLI is required, just spawn a new MCP child (open a new session) for changes to take effect. Unrecognized values fall back to enabled.
1416
1401
 
1417
1402
  ### Routing-guidance environment variables
1418
1403
 
@@ -402,7 +402,7 @@ export class CodexAdapter extends BaseAdapter {
402
402
  }]);
403
403
  }
404
404
  const expected = this.generateHookConfig("");
405
- return results.concat(Object.entries(expected).map(([hookName, entries]) => {
405
+ const hookChecks = Object.entries(expected).map(([hookName, entries]) => {
406
406
  const actualEntries = hookConfig.config.hooks?.[hookName];
407
407
  const expectedEntry = entries[0];
408
408
  const ok = Array.isArray(actualEntries)
@@ -410,7 +410,7 @@ export class CodexAdapter extends BaseAdapter {
410
410
  const missingStatus = hookName === "PreCompact" ? "warn" : "fail";
411
411
  return {
412
412
  check: `${hookName} hook`,
413
- status: ok ? "pass" : missingStatus,
413
+ status: (ok ? "pass" : missingStatus),
414
414
  message: ok
415
415
  ? `${hookName} hook configured in ${this.getHooksPath()}`
416
416
  : hookName === "PreCompact"
@@ -418,7 +418,28 @@ export class CodexAdapter extends BaseAdapter {
418
418
  : `${hookName} hook missing or not pointing to context-mode`,
419
419
  fix: ok ? undefined : `Update ${this.getHooksPath()} to match configs/codex/hooks.json`,
420
420
  };
421
- }));
421
+ });
422
+ // #603: surface duplicate context-mode entries per hook event. Codex fires
423
+ // every matching entry, so duplicates double the work, can saturate the
424
+ // MCP transport (`Transport closed`), and have been observed to inflate
425
+ // codex-tui.log into the multi-GB range. `context-mode upgrade` collapses
426
+ // them via `upsertManagedHookEntry`, so the fix is one command away.
427
+ const duplicateChecks = [];
428
+ for (const hookName of Object.keys(expected)) {
429
+ const actualEntries = hookConfig.config.hooks?.[hookName];
430
+ if (!Array.isArray(actualEntries))
431
+ continue;
432
+ const managedCount = actualEntries.filter((entry) => this.isManagedContextModeEntry(hookName, entry)).length;
433
+ if (managedCount > 1) {
434
+ duplicateChecks.push({
435
+ check: `${hookName} duplicates`,
436
+ status: "warn",
437
+ message: `${managedCount} context-mode entries found for ${hookName} in ${this.getHooksPath()}; Codex will fire all of them`,
438
+ fix: "context-mode upgrade (collapses duplicate context-mode entries; preserves unrelated hooks)",
439
+ });
440
+ }
441
+ }
442
+ return results.concat(hookChecks, duplicateChecks);
422
443
  }
423
444
  checkPluginRegistration() {
424
445
  // Check for context-mode in [mcp_servers] section of config.toml
@@ -45,7 +45,15 @@ export declare function isContextModeHook(entry: {
45
45
  }, hookType: HookType): boolean;
46
46
  /**
47
47
  * Build the hook command string for a given hook type.
48
- * Uses absolute node path to avoid PATH issues (homebrew, nvm, volta, etc.).
49
- * Falls back to CLI dispatcher if pluginRoot is not provided.
48
+ *
49
+ * Always emits the CLI dispatcher form
50
+ * (`context-mode hook jetbrains-copilot <event>`) — the `pluginRoot`
51
+ * argument is accepted for API compatibility but intentionally ignored.
52
+ *
53
+ * Same Tier C contract as VS Code Copilot (Issue #613):
54
+ * `.github/hooks/context-mode.json` is workspace-committed (team-shared
55
+ * via git). Embedding `process.execPath` or absolute pluginRoot paths
56
+ * leaks PII and breaks cross-machine portability. See
57
+ * src/adapters/vscode-copilot/hooks.ts for the full archaeology.
50
58
  */
51
- export declare function buildHookCommand(hookType: HookType, pluginRoot?: string): string;
59
+ export declare function buildHookCommand(hookType: HookType, _pluginRoot?: string): string;
@@ -1,4 +1,3 @@
1
- import { buildNodeCommand } from "../types.js";
2
1
  /**
3
2
  * adapters/jetbrains-copilot/hooks — JetBrains Copilot hook definitions and matchers.
4
3
  *
@@ -68,16 +67,21 @@ export function isContextModeHook(entry, hookType) {
68
67
  }
69
68
  /**
70
69
  * Build the hook command string for a given hook type.
71
- * Uses absolute node path to avoid PATH issues (homebrew, nvm, volta, etc.).
72
- * Falls back to CLI dispatcher if pluginRoot is not provided.
70
+ *
71
+ * Always emits the CLI dispatcher form
72
+ * (`context-mode hook jetbrains-copilot <event>`) — the `pluginRoot`
73
+ * argument is accepted for API compatibility but intentionally ignored.
74
+ *
75
+ * Same Tier C contract as VS Code Copilot (Issue #613):
76
+ * `.github/hooks/context-mode.json` is workspace-committed (team-shared
77
+ * via git). Embedding `process.execPath` or absolute pluginRoot paths
78
+ * leaks PII and breaks cross-machine portability. See
79
+ * src/adapters/vscode-copilot/hooks.ts for the full archaeology.
73
80
  */
74
- export function buildHookCommand(hookType, pluginRoot) {
81
+ export function buildHookCommand(hookType, _pluginRoot) {
75
82
  const scriptName = HOOK_SCRIPTS[hookType];
76
83
  if (!scriptName) {
77
84
  throw new Error(`No script defined for hook type: ${hookType}`);
78
85
  }
79
- if (pluginRoot) {
80
- return buildNodeCommand(`${pluginRoot}/hooks/jetbrains-copilot/${scriptName}`);
81
- }
82
86
  return `context-mode hook jetbrains-copilot ${hookType.toLowerCase()}`;
83
87
  }
@@ -58,6 +58,7 @@ export declare class OpenCodeAdapter extends BaseAdapter implements HookAdapter
58
58
  * Check whether a settings object has the context-mode plugin registered.
59
59
  */
60
60
  private hasContextModePlugin;
61
+ private hasLegacyContextModeMcp;
61
62
  /**
62
63
  * Extract session ID from OpenCode hook input.
63
64
  * OpenCode uses camelCase sessionID.
@@ -310,6 +310,14 @@ export class OpenCodeAdapter extends BaseAdapter {
310
310
  fix: "context-mode upgrade",
311
311
  });
312
312
  }
313
+ if (this.hasLegacyContextModeMcp(settings)) {
314
+ results.push({
315
+ check: "Legacy MCP registration",
316
+ status: "warn",
317
+ message: "mcp.context-mode is redundant: ctx_* tools are now provided by the plugin",
318
+ fix: "context-mode upgrade (removes only mcp.context-mode; preserves other MCP servers)",
319
+ });
320
+ }
313
321
  // Note: SessionStart handled via experimental.chat.system.transform surrogate
314
322
  results.push({
315
323
  check: "SessionStart hook",
@@ -368,6 +376,16 @@ export class OpenCodeAdapter extends BaseAdapter {
368
376
  changes.push("context-mode already in plugin array");
369
377
  }
370
378
  settings.plugin = plugins;
379
+ const mcp = settings.mcp;
380
+ if (mcp && typeof mcp === "object" && !Array.isArray(mcp)) {
381
+ const servers = mcp;
382
+ if (Object.prototype.hasOwnProperty.call(servers, "context-mode")) {
383
+ delete servers["context-mode"];
384
+ changes.push("Removed legacy context-mode MCP block (plugin-native tools)");
385
+ }
386
+ if (Object.keys(servers).length === 0)
387
+ delete settings.mcp;
388
+ }
371
389
  this.writeSettings(settings);
372
390
  return changes;
373
391
  }
@@ -405,6 +423,13 @@ export class OpenCodeAdapter extends BaseAdapter {
405
423
  const plugins = settings.plugin;
406
424
  return Array.isArray(plugins) && plugins.some((p) => typeof p === "string" && p.includes("context-mode"));
407
425
  }
426
+ hasLegacyContextModeMcp(settings) {
427
+ const mcp = settings.mcp;
428
+ return !!(mcp &&
429
+ typeof mcp === "object" &&
430
+ !Array.isArray(mcp) &&
431
+ Object.prototype.hasOwnProperty.call(mcp, "context-mode"));
432
+ }
408
433
  /**
409
434
  * Extract session ID from OpenCode hook input.
410
435
  * OpenCode uses camelCase sessionID.
@@ -44,6 +44,27 @@ type PluginContext = {
44
44
  client: PluginClient;
45
45
  directory: string;
46
46
  };
47
+ type NativeToolContext = {
48
+ sessionID: string;
49
+ messageID: string;
50
+ agent: string;
51
+ directory: string;
52
+ worktree?: string;
53
+ abort?: AbortSignal;
54
+ metadata?: (input: {
55
+ title?: string;
56
+ metadata?: Record<string, unknown>;
57
+ }) => void;
58
+ };
59
+ type NativeToolDefinition = {
60
+ description: string;
61
+ args: Record<string, unknown>;
62
+ execute: (args: Record<string, unknown>, ctx: NativeToolContext) => Promise<string | {
63
+ title?: string;
64
+ output: string;
65
+ metadata?: Record<string, unknown>;
66
+ }>;
67
+ };
47
68
  /** OpenCode tool.execute.before — first parameter */
48
69
  interface BeforeHookInput {
49
70
  tool: string;
@@ -130,6 +151,7 @@ declare function systemHasRoutingInstructions(system: string[]): boolean;
130
151
  * OpenCode expects: export const ContextModePlugin = (ctx) => Promise<Hooks>
131
152
  */
132
153
  declare function createContextModePlugin(ctx: PluginContext): Promise<{
154
+ tool: Record<string, NativeToolDefinition>;
133
155
  "tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
134
156
  "tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
135
157
  "chat.message": (input: ChatMessageHookInput, output: ChatMessageHookOutput) => Promise<void>;
@@ -231,7 +231,59 @@ async function createContextModePlugin(ctx) {
231
231
  // Never break the turn on debug-log failure.
232
232
  }
233
233
  }
234
+ async function buildNativeTools() {
235
+ // Import the existing MCP server registry without starting its stdio
236
+ // transport. This is the plugin-only bridge for #574: OpenCode/Kilo
237
+ // call ctx_* tools in-process through Hooks.tool instead of spawning
238
+ // a separate MCP child per session.
239
+ const prevEmbedded = process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
240
+ process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS = "1";
241
+ let mod;
242
+ try {
243
+ mod = await import("../../server.js");
244
+ }
245
+ finally {
246
+ if (prevEmbedded === undefined)
247
+ delete process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
248
+ else
249
+ process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS = prevEmbedded;
250
+ }
251
+ const tools = {};
252
+ for (const registered of mod.REGISTERED_CTX_TOOLS) {
253
+ const config = registered.config;
254
+ const schema = config.inputSchema;
255
+ const shape = typeof schema?.shape === "object" && schema.shape !== null
256
+ ? schema.shape
257
+ : typeof schema?._def?.shape === "function"
258
+ ? schema._def.shape()
259
+ : {};
260
+ tools[registered.name] = {
261
+ description: String(config.description ?? ""),
262
+ args: shape,
263
+ async execute(args, toolCtx) {
264
+ toolCtx.metadata?.({ title: String(config.title ?? registered.name) });
265
+ const project = toolCtx.directory || projectDir;
266
+ const result = await mod.withProjectDirOverride({ projectDir: project, sessionId: toolCtx.sessionID }, async () => registered.handler(args ?? {}));
267
+ const r = result;
268
+ const text = Array.isArray(r?.content)
269
+ ? r.content
270
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
271
+ .map((c) => c.text)
272
+ .join("\n")
273
+ : typeof result === "string"
274
+ ? result
275
+ : JSON.stringify(result ?? "");
276
+ if (r?.isError)
277
+ throw new Error(text || `${registered.name} returned an error`);
278
+ return { title: String(config.title ?? registered.name), output: text };
279
+ },
280
+ };
281
+ }
282
+ return tools;
283
+ }
284
+ const nativeTools = await buildNativeTools();
234
285
  return {
286
+ tool: nativeTools,
235
287
  // ── PreToolUse: Routing enforcement ─────────────────
236
288
  "tool.execute.before": async (input, output) => {
237
289
  const toolName = input.tool ?? "";
@@ -288,6 +288,11 @@ export default function piExtension(pi) {
288
288
  pwd: process.env.PWD,
289
289
  cwd: process.cwd(),
290
290
  });
291
+ // Attribution object for project isolation — ensures every event recorded
292
+ // by the pi adapter carries the correct project_dir. Without this, all
293
+ // events default to project_dir="" which causes cross-project data leakage
294
+ // in shared SessionDB instances.
295
+ const _attribution = { projectDir, source: "workspace_root", confidence: 0.98 };
291
296
  const db = getOrCreateDB();
292
297
  // ── 1. session_start — Initialize session ──────────────
293
298
  pi.on("session_start", (_event, ctx) => {
@@ -351,7 +356,7 @@ export default function piExtension(pi) {
351
356
  const events = extractEvents(hookInput);
352
357
  if (events.length > 0) {
353
358
  for (const ev of events) {
354
- db.insertEvent(_sessionId, ev, "PostToolUse");
359
+ db.insertEvent(_sessionId, ev, "PostToolUse", _attribution);
355
360
  }
356
361
  }
357
362
  else if (rawToolName) {
@@ -369,7 +374,7 @@ export default function piExtension(pi) {
369
374
  .update(data)
370
375
  .digest("hex")
371
376
  .slice(0, 16),
372
- }, "PostToolUse");
377
+ }, "PostToolUse", _attribution);
373
378
  }
374
379
  }
375
380
  catch {
@@ -398,7 +403,7 @@ export default function piExtension(pi) {
398
403
  if (prompt) {
399
404
  const userEvents = extractUserEvents(prompt);
400
405
  for (const ev of userEvents) {
401
- db.insertEvent(_sessionId, ev, "UserPromptSubmit");
406
+ db.insertEvent(_sessionId, ev, "UserPromptSubmit", _attribution);
402
407
  }
403
408
  }
404
409
  const existingPrompt = String(event?.systemPrompt ?? "");
@@ -420,6 +425,7 @@ export default function piExtension(pi) {
420
425
  minPriority: 3,
421
426
  limit: 50,
422
427
  });
428
+ let behavioralDirective = "";
423
429
  if (activeEvents.length > 0) {
424
430
  const buildAuto = await getAutoInjection(pluginRoot);
425
431
  let memoryContext = "";
@@ -428,6 +434,14 @@ export default function piExtension(pi) {
428
434
  category: String(e.category ?? ""),
429
435
  data: String(e.data ?? ""),
430
436
  })));
437
+ const bdMatch = memoryContext.match(/(<behavioral_directive>\n[^<]*\n<\/behavioral_directive>)/);
438
+ if (bdMatch) {
439
+ behavioralDirective = bdMatch[1];
440
+ memoryContext = memoryContext.replace(bdMatch[1], "");
441
+ if (memoryContext.match(/^<session_state[^>]*>\s*<\/session_state>\s*$/)) {
442
+ memoryContext = "";
443
+ }
444
+ }
431
445
  }
432
446
  // Fallback (or if helper produced empty output): inline 500-token cap.
433
447
  if (!memoryContext) {
@@ -453,6 +467,8 @@ export default function piExtension(pi) {
453
467
  parts.push(resume.snapshot);
454
468
  db.markResumeConsumed(_sessionId);
455
469
  }
470
+ if (behavioralDirective)
471
+ parts.push(behavioralDirective);
456
472
  // Return modified systemPrompt only if we added something beyond existing.
457
473
  const baseLen = existingPrompt ? 1 : 0;
458
474
  if (parts.length > baseLen) {
@@ -491,7 +507,7 @@ export default function piExtension(pi) {
491
507
  data,
492
508
  priority: 1,
493
509
  data_hash: createHash("sha256").update(data).digest("hex").slice(0, 16),
494
- }, "PostToolUse");
510
+ }, "PostToolUse", _attribution);
495
511
  }
496
512
  catch {
497
513
  // best effort — never break provider response
@@ -97,12 +97,13 @@ export declare class MCPStdioClient {
97
97
  private onExit;
98
98
  private onData;
99
99
  request<T = unknown>(method: string, params: unknown, timeoutMs?: number): Promise<T>;
100
+ private writeFrame;
100
101
  notify(method: string, params: unknown): void;
101
102
  initialize(): Promise<void>;
102
103
  listTools(): Promise<MCPTool[]>;
103
104
  callTool(name: string, args: unknown): Promise<MCPCallResult>;
104
105
  /**
105
- * Respawn the MCP child after an exit (clean idle shutdown or crash).
106
+ * Respawn the MCP child after an exit (clean shutdown or crash).
106
107
  * Resets state so a fresh `start()` + `initialize()` cycle runs, then
107
108
  * the caller's pending request flows through the new child.
108
109
  *
@@ -366,14 +366,60 @@ export class MCPStdioClient {
366
366
  },
367
367
  });
368
368
  const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params });
369
- this.child.stdin?.write(frame + "\n");
369
+ const rejectWrite = (err) => {
370
+ const handler = this.pending.get(id);
371
+ if (handler) {
372
+ this.pending.delete(id);
373
+ handler.reject(err);
374
+ return;
375
+ }
376
+ reject(err);
377
+ };
378
+ this.writeFrame(frame, rejectWrite);
370
379
  });
371
380
  }
381
+ writeFrame(frame, onError) {
382
+ if (!this.child || this.exited) {
383
+ onError?.(new Error("MCP server exited"));
384
+ return false;
385
+ }
386
+ const stdin = this.child.stdin;
387
+ if (!stdin || stdin.destroyed || stdin.writableEnded || stdin.closed) {
388
+ this.onExit();
389
+ onError?.(new Error("MCP server stdin unavailable"));
390
+ return false;
391
+ }
392
+ try {
393
+ stdin.write(frame + "\n", (err) => {
394
+ if (!err)
395
+ return;
396
+ const code = err.code;
397
+ if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
398
+ this.onExit();
399
+ onError?.(err);
400
+ return;
401
+ }
402
+ onError?.(err);
403
+ });
404
+ return true;
405
+ }
406
+ catch (err) {
407
+ const code = err && typeof err === "object" && "code" in err
408
+ ? err.code
409
+ : undefined;
410
+ if (err instanceof Error && (code === "EPIPE" || code === "ERR_STREAM_DESTROYED")) {
411
+ this.onExit();
412
+ onError?.(err);
413
+ return false;
414
+ }
415
+ throw err;
416
+ }
417
+ }
372
418
  notify(method, params) {
373
419
  if (!this.child)
374
420
  return;
375
421
  const frame = JSON.stringify({ jsonrpc: "2.0", method, params });
376
- this.child.stdin?.write(frame + "\n");
422
+ this.writeFrame(frame);
377
423
  }
378
424
  async initialize() {
379
425
  if (this.initialized)
@@ -402,7 +448,7 @@ export class MCPStdioClient {
402
448
  return this.request("tools/call", { name, arguments: args ?? {} }, DEFAULT_CALL_TIMEOUT_MS);
403
449
  }
404
450
  /**
405
- * Respawn the MCP child after an exit (clean idle shutdown or crash).
451
+ * Respawn the MCP child after an exit (clean shutdown or crash).
406
452
  * Resets state so a fresh `start()` + `initialize()` cycle runs, then
407
453
  * the caller's pending request flows through the new child.
408
454
  *
@@ -41,7 +41,31 @@ export declare function isContextModeHook(entry: {
41
41
  }, hookType: HookType): boolean;
42
42
  /**
43
43
  * Build the hook command string for a given hook type.
44
- * Uses absolute node path to avoid PATH issues (homebrew, nvm, volta, etc.).
45
- * Falls back to CLI dispatcher if pluginRoot is not provided.
44
+ *
45
+ * Always emits the CLI dispatcher form
46
+ * (`context-mode hook vscode-copilot <event>`) — the `pluginRoot` argument
47
+ * is accepted for API compatibility but intentionally ignored.
48
+ *
49
+ * Why the dispatcher form is mandatory here (Issue #613 — Tier C contract):
50
+ * `.github/hooks/context-mode.json` is a **workspace-committed** file
51
+ * (upstream: refs/platforms/vscode-copilot/assets/prompts/skills/
52
+ * agent-customization/references/hooks.md line 7 — "Workspace
53
+ * (team-shared)"). It lands in every teammate's `git status`. Embedding
54
+ * `process.execPath` or any absolute pluginRoot path here:
55
+ * - Leaks PII (username, `C:/Users/<user>/...` paths).
56
+ * - Breaks cross-machine portability (fnm/nvm/volta/brew shims are
57
+ * per-shell-session ephemeral; the path goes stale immediately on
58
+ * Windows + fnm).
59
+ *
60
+ * Commit `f5c9d02` (2026-03-06) added an absolute-path branch when a
61
+ * pluginRoot was passed. It solved a real PATH-availability bug on
62
+ * Brew/nvm setups by going too far — the CLI then always passes
63
+ * pluginRoot, so the portable form became unreachable in production
64
+ * and every `/ctx-upgrade` baked a non-portable command into the
65
+ * committed config. This reverts to the pre-`f5c9d02` shape.
66
+ *
67
+ * For users without a global install, the recovery path is the same as
68
+ * every other CLI-dispatcher adapter (cursor, codex):
69
+ * `npm install -g context-mode`
46
70
  */
47
- export declare function buildHookCommand(hookType: HookType, pluginRoot?: string): string;
71
+ export declare function buildHookCommand(hookType: HookType, _pluginRoot?: string): string;