context-mode 1.0.135 → 1.0.136

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 (37) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/hooks.json +65 -0
  4. package/.codex-plugin/mcp.json +9 -0
  5. package/.codex-plugin/plugin.json +31 -0
  6. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  7. package/.openclaw-plugin/package.json +1 -1
  8. package/README.md +60 -12
  9. package/build/adapters/pi/mcp-bridge.d.ts +37 -1
  10. package/build/adapters/pi/mcp-bridge.js +135 -21
  11. package/build/lifecycle.d.ts +13 -13
  12. package/build/lifecycle.js +14 -14
  13. package/build/session/extract.js +39 -1
  14. package/cli.bundle.mjs +3 -3
  15. package/configs/kilo/kilo.json +9 -2
  16. package/configs/opencode/opencode.json +9 -2
  17. package/hooks/codex/platform.mjs +1 -0
  18. package/hooks/codex/posttooluse.mjs +1 -0
  19. package/hooks/codex/precompact.mjs +1 -0
  20. package/hooks/codex/pretooluse.mjs +1 -0
  21. package/hooks/codex/sessionstart.mjs +1 -0
  22. package/hooks/codex/stop.mjs +1 -0
  23. package/hooks/codex/userpromptsubmit.mjs +1 -0
  24. package/hooks/core/routing.mjs +112 -10
  25. package/hooks/ensure-deps.mjs +14 -3
  26. package/hooks/session-extract.bundle.mjs +2 -2
  27. package/openclaw.plugin.json +1 -1
  28. package/package.json +2 -1
  29. package/server.bundle.mjs +4 -4
  30. package/build/openclaw-plugin.d.ts +0 -130
  31. package/build/openclaw-plugin.js +0 -626
  32. package/build/opencode-plugin.d.ts +0 -122
  33. package/build/opencode-plugin.js +0 -372
  34. package/build/pi-extension.d.ts +0 -14
  35. package/build/pi-extension.js +0 -451
  36. package/build/util/db-lock.d.ts +0 -65
  37. package/build/util/db-lock.js +0 -166
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.135"
9
+ "version": "1.0.136"
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.135",
16
+ "version": "1.0.136",
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.135",
3
+ "version": "1.0.136",
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",
@@ -0,0 +1,65 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "local_shell|shell|shell_command|exec_command|Bash|Shell|apply_patch|Edit|Write|grep_files|ctx_execute|ctx_execute_file|ctx_batch_execute|ctx_fetch_and_index|ctx_search|ctx_index|mcp__",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${PLUGIN_ROOT}/hooks/codex/pretooluse.mjs\""
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "PostToolUse": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"${PLUGIN_ROOT}/hooks/codex/posttooluse.mjs\""
20
+ }
21
+ ]
22
+ }
23
+ ],
24
+ "SessionStart": [
25
+ {
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "node \"${PLUGIN_ROOT}/hooks/codex/sessionstart.mjs\""
30
+ }
31
+ ]
32
+ }
33
+ ],
34
+ "PreCompact": [
35
+ {
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "node \"${PLUGIN_ROOT}/hooks/codex/precompact.mjs\""
40
+ }
41
+ ]
42
+ }
43
+ ],
44
+ "UserPromptSubmit": [
45
+ {
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "node \"${PLUGIN_ROOT}/hooks/codex/userpromptsubmit.mjs\""
50
+ }
51
+ ]
52
+ }
53
+ ],
54
+ "Stop": [
55
+ {
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": "node \"${PLUGIN_ROOT}/hooks/codex/stop.mjs\""
60
+ }
61
+ ]
62
+ }
63
+ ]
64
+ }
65
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "mcpServers": {
3
+ "context-mode": {
4
+ "command": "node",
5
+ "args": ["./start.mjs"],
6
+ "cwd": "."
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "context-mode",
3
+ "version": "1.0.136",
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
+ "author": {
6
+ "name": "Mert Koseoğlu",
7
+ "url": "https://github.com/mksglu"
8
+ },
9
+ "homepage": "https://github.com/mksglu/context-mode#readme",
10
+ "repository": "https://github.com/mksglu/context-mode",
11
+ "license": "Elastic-2.0",
12
+ "keywords": [
13
+ "mcp",
14
+ "context-window",
15
+ "sandbox",
16
+ "code-execution",
17
+ "fts5",
18
+ "bm25",
19
+ "playwright",
20
+ "context7"
21
+ ],
22
+ "mcpServers": "./.codex-plugin/mcp.json",
23
+ "hooks": "./.codex-plugin/hooks.json",
24
+ "skills": "./skills/",
25
+ "interface": {
26
+ "displayName": "context-mode",
27
+ "shortDescription": "98% context window savings via FTS5 + sandboxed execution",
28
+ "developerName": "Mert Koseoğlu",
29
+ "category": "Productivity"
30
+ }
31
+ }
@@ -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.135",
6
+ "version": "1.0.136",
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.135",
3
+ "version": "1.0.136",
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
@@ -424,7 +424,10 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
424
424
  "mcp": {
425
425
  "context-mode": {
426
426
  "type": "local",
427
- "command": ["context-mode"]
427
+ "command": ["context-mode"],
428
+ "environment": {
429
+ "CONTEXT_MODE_IDLE_TIMEOUT_MS": "900000"
430
+ }
428
431
  }
429
432
  },
430
433
  "plugin": ["context-mode"]
@@ -474,7 +477,10 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
474
477
  "mcp": {
475
478
  "context-mode": {
476
479
  "type": "local",
477
- "command": ["context-mode"]
480
+ "command": ["context-mode"],
481
+ "environment": {
482
+ "CONTEXT_MODE_IDLE_TIMEOUT_MS": "900000"
483
+ }
478
484
  }
479
485
  },
480
486
  "plugin": ["context-mode"]
@@ -545,6 +551,45 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
545
551
 
546
552
  **Install:**
547
553
 
554
+ 1. Add the context-mode marketplace and install the plugin from Codex's plugin UI:
555
+
556
+ ```bash
557
+ codex plugin marketplace add mksglu/context-mode
558
+ ```
559
+
560
+ 2. Enable plugin-provided hooks while the Codex feature is still gated:
561
+
562
+ ```toml
563
+ [features]
564
+ plugin_hooks = true
565
+ hooks = true
566
+ ```
567
+
568
+ > **Feature flag note:** Current Codex builds expose hooks under `[features].hooks`
569
+ > (or `codex --enable hooks`). Prefer `[features].hooks`; `[features].codex_hooks`
570
+ > remains accepted as a legacy alias in current Codex builds. Bundled plugin hooks
571
+ > additionally require `plugin_hooks` until Codex enables plugin hooks by default.
572
+
573
+ 3. Restart Codex CLI and verify MCP with `ctx stats`.
574
+
575
+ `ctx stats` proves the plugin MCP server is installed and reachable; it does
576
+ not prove hooks are trusted or running.
577
+
578
+ 4. Review and trust the context-mode plugin hooks if Codex prompts for hook
579
+ approval. Plugin hooks are only active after both feature flags are enabled
580
+ and Codex has accepted the hook commands.
581
+
582
+ The Codex plugin manifest provides MCP via `.codex-plugin/mcp.json`, skills via
583
+ `skills/`, and bundled hooks via `.codex-plugin/hooks.json`. No manual
584
+ `[mcp_servers.context-mode]` block or `$CODEX_HOME/hooks.json` is needed when
585
+ `plugin_hooks` is enabled and the plugin hooks are trusted.
586
+
587
+ > **Node/PATH note:** context-mode still needs `node` visible to the Codex process.
588
+ > The plugin removes manual Codex config, but it does not vendor Node or inherit
589
+ > login-shell PATH fixes automatically.
590
+
591
+ **Manual fallback for Codex builds without `plugin_hooks`:**
592
+
548
593
  1. Install context-mode globally:
549
594
 
550
595
  ```bash
@@ -561,11 +606,7 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
561
606
  command = "context-mode"
562
607
  ```
563
608
 
564
- > **Feature flag note:** Current Codex builds expose hooks under `[features].hooks`
565
- > (or `codex --enable hooks`). Prefer `[features].hooks`; `[features].codex_hooks`
566
- > remains accepted as a legacy alias in current Codex builds.
567
-
568
- 3. Add hooks for routing enforcement and session tracking. Create `$CODEX_HOME/hooks.json` (or `~/.codex/hooks.json` when `CODEX_HOME` is unset):
609
+ 3. Create `$CODEX_HOME/hooks.json` (or `~/.codex/hooks.json` when `CODEX_HOME` is unset):
569
610
 
570
611
  ```json
571
612
  {
@@ -597,9 +638,9 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
597
638
 
598
639
  5. Restart Codex CLI.
599
640
 
600
- **Verify:** Start a session and type `ctx stats`. Context-mode tools should appear and respond.
641
+ **Verify:** Start a session and type `ctx stats` to verify MCP. To verify hook routing, confirm Codex lists/trusts the context-mode plugin hooks, then run a command that matches the routing rules.
601
642
 
602
- **Routing:** MCP tools work. Hook-based routing is active when `$CODEX_HOME/hooks.json` or `~/.codex/hooks.json` is configured. The `AGENTS.md` file provides routing instructions for model awareness.
643
+ **Routing:** MCP tools work after plugin install. Plugin hook routing is active only when `hooks` and `plugin_hooks` are enabled and Codex trusts the plugin hook commands. Manual hook routing is active when `$CODEX_HOME/hooks.json` or `~/.codex/hooks.json` is configured. The `AGENTS.md` file provides routing instructions for model awareness.
603
644
 
604
645
  </details>
605
646
 
@@ -1157,6 +1198,7 @@ Detailed event data is also indexed into FTS5 for on-demand retrieval via `ctx_s
1157
1198
  **Kiro** — Partial coverage. Native `preToolUse` and `postToolUse` hooks capture tool events and enforce sandbox routing. `agentSpawn` (the Kiro equivalent of SessionStart) is not yet implemented, so session restore after compaction is not available. Requires manually copying `KIRO.md` to your project root. Auto-detected via MCP protocol handshake (`clientInfo.name`).
1158
1199
 
1159
1200
  **Pi Coding Agent** — High coverage. The extension registers all key lifecycle events: `tool_call` (PreToolUse), `tool_result` (PostToolUse), `session_start` (SessionStart), and `session_before_compact` (PreCompact). File edits, git ops, errors, and tasks are fully tracked. Session restore after compaction works via the extension's event hooks.
1201
+ Tool call output can be collapsed/expanded with the default Pi's default keybinding (Ctrl+O)
1160
1202
 
1161
1203
  **OMP (Oh My Pi)** — High coverage. The plugin (installed via `omp plugin install context-mode`) registers all key lifecycle events: `tool_call` (PreToolUse), `tool_result` (PostToolUse), `session_start` (SessionStart), and `session_before_compact` (PreCompact). Storage roots cleanly under `~/.omp/context-mode/` so OMP and Pi installs never share state (issue [#473](https://github.com/mksglu/context-mode/issues/473)). Auto-detected via `PI_CODING_AGENT_DIR` env var or presence of `~/.omp/`.
1162
1204
 
@@ -1363,14 +1405,20 @@ That blocks loopback + RFC1918 + ULA in addition to the always-blocked ranges. U
1363
1405
 
1364
1406
  ### Lifecycle environment variables
1365
1407
 
1366
- Two runtime knobs control how MCP server processes self-manage. Defaults are safe only set these to opt-out of the leak-fix introduced in v1.0.132 ([#565](https://github.com/mksglu/context-mode/issues/565) / [#568](https://github.com/mksglu/context-mode/pull/568)).
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.
1367
1409
 
1368
1410
  | Variable | Default | Purpose |
1369
1411
  |---|---|---|
1370
- | `CONTEXT_MODE_IDLE_TIMEOUT_MS` | `900000` (15 min) | An MCP child self-exits cleanly after this many milliseconds of stdin/request inactivity. Hosts like OpenCode and KiloCode open one MCP child per session and per subagent without this, idle children accumulate to 25+ processes / 1.6 GB RSS in long-lived shells. Set to `0` to disable self-shutdown (rarely needed; useful only for daemons that must outlive their parent). |
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. |
1371
1413
  | `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). |
1372
1414
 
1373
- 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 values (non-numeric `CONTEXT_MODE_IDLE_TIMEOUT_MS`, unrecognized `CONTEXT_MODE_STARTUP_SWEEP`) fall back to defaults silently.
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.
1416
+
1417
+ ### Routing-guidance environment variables
1418
+
1419
+ | Variable | Default | Purpose |
1420
+ |---|---|---|
1421
+ | `CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY` | `10` | Cadence (in tool calls) at which the PreToolUse hook re-injects the "wrap large external-MCP payloads in `ctx_execute`" guidance. The original implementation ([#529](https://github.com/mksglu/context-mode/pull/529)) fired only once per session, which got lost after context compaction in MCP-heavy sessions (e.g. 50+ Jira/Slack/Notion calls — see [#567](https://github.com/mksglu/context-mode/issues/567) follow-up). The default re-fires every 10th matching call, keeping the guidance in the model's recent window. Range `[1, 100]`; invalid values fall back to `10`. Set to `1` for "every call" (most aggressive — adds ~250 tokens/call) or to a larger value for less frequent reminders. |
1374
1422
 
1375
1423
  ## Contributing
1376
1424
 
@@ -47,6 +47,13 @@ export interface MCPCallResult {
47
47
  }>;
48
48
  isError?: boolean;
49
49
  }
50
+ interface PiRenderTheme {
51
+ bold(text: string): string;
52
+ fg(color: string, text: string): string;
53
+ }
54
+ interface PiRenderContext {
55
+ lastComponent?: unknown;
56
+ }
50
57
  /**
51
58
  * Minimal stdio JSON-RPC client targeting the context-mode MCP server.
52
59
  *
@@ -69,6 +76,15 @@ export declare class MCPStdioClient {
69
76
  private buffer;
70
77
  private initialized;
71
78
  private exited;
79
+ /**
80
+ * In-flight respawn promise — set while {@link respawn} runs so
81
+ * concurrent callers awaiting `request()` after an idle exit observe
82
+ * the SAME respawn, not N parallel ones. Without this guard, two
83
+ * simultaneous `callTool` calls would each see `this.exited === true`,
84
+ * each fire their own `respawn()`, and the loser leaks an orphaned
85
+ * child process the GC cannot reach (no `.kill()` reference).
86
+ */
87
+ private respawnPromise;
72
88
  /**
73
89
  * Live env passed to the spawned child — exposed (read-only intent)
74
90
  * so tests can pin the fork-bomb-prevention env counter (#516)
@@ -90,7 +106,21 @@ export declare class MCPStdioClient {
90
106
  * Resets state so a fresh `start()` + `initialize()` cycle runs, then
91
107
  * the caller's pending request flows through the new child.
92
108
  *
93
- * Internalexposed only via the public `callTool()` happy path.
109
+ * Single-flightconcurrent callers share one in-flight respawn via
110
+ * {@link respawnPromise}. Internal — only entered via {@link request}.
111
+ *
112
+ * Sequencing pinned (do not reorder without updating the regression
113
+ * test in tests/adapters/pi-mcp-bridge.test.ts):
114
+ * 1. `this.child = null` — drop stale handle
115
+ * 2. `this.buffer = ""` — discard leftover bytes from old child
116
+ * 3. `this.exited = false` — must precede `start()` + `initialize()`,
117
+ * because `request("initialize", …)`
118
+ * inside `initialize()` re-checks this
119
+ * flag and would otherwise re-enter
120
+ * respawn in an infinite loop
121
+ * 4. `this.initialized = false`
122
+ * 5. `this.start()`
123
+ * 6. `await this.initialize()` — flows through `request()` recursively
94
124
  */
95
125
  private respawn;
96
126
  shutdown(): void;
@@ -106,6 +136,11 @@ export interface PiToolRegistration {
106
136
  label: string;
107
137
  description: string;
108
138
  parameters: unknown;
139
+ renderCall?: (args: unknown, theme: PiRenderTheme, context: PiRenderContext) => unknown;
140
+ renderResult?: (result: MCPCallResult, options: {
141
+ expanded: boolean;
142
+ isPartial: boolean;
143
+ }, theme: PiRenderTheme, context: PiRenderContext) => unknown;
109
144
  execute: (toolCallId: string, params: Record<string, unknown>) => Promise<{
110
145
  content: Array<{
111
146
  type: "text";
@@ -144,3 +179,4 @@ export interface BootstrapOptions {
144
179
  _resolveJsRuntime?: () => string | null;
145
180
  }
146
181
  export declare function bootstrapMCPTools(pi: PiLikeAPI, serverScript: string, options?: BootstrapOptions): Promise<BridgeHandle>;
182
+ export {};
@@ -96,6 +96,87 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
96
96
  // Tools/call may run shell commands or fetch URLs — wider window than
97
97
  // initialize/list, but still bounded so a hung server can't block Pi.
98
98
  const DEFAULT_CALL_TIMEOUT_MS = 120_000;
99
+ class PiTextComponent {
100
+ text;
101
+ constructor(text = "") {
102
+ this.text = text;
103
+ }
104
+ setText(text) {
105
+ this.text = text;
106
+ }
107
+ invalidate() {
108
+ // Stateless renderer: no cached layout to invalidate.
109
+ }
110
+ render(width) {
111
+ if (!this.text || this.text.trim() === "")
112
+ return [];
113
+ return this.text
114
+ .replace(/\t/g, " ")
115
+ .split(/\r?\n/)
116
+ .map((line) => truncateAnsiLine(line, Math.max(1, width)));
117
+ }
118
+ }
119
+ const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
120
+ function truncateAnsiLine(line, maxWidth) {
121
+ if (maxWidth <= 0)
122
+ return "";
123
+ let output = "";
124
+ let visible = 0;
125
+ let index = 0;
126
+ ANSI_PATTERN.lastIndex = 0;
127
+ for (;;) {
128
+ const match = ANSI_PATTERN.exec(line);
129
+ const end = match?.index ?? line.length;
130
+ const chunk = line.slice(index, end);
131
+ for (const char of chunk) {
132
+ if (visible >= maxWidth)
133
+ return output;
134
+ output += char;
135
+ visible++;
136
+ }
137
+ if (!match)
138
+ return output;
139
+ output += match[0];
140
+ index = ANSI_PATTERN.lastIndex;
141
+ }
142
+ }
143
+ function createContextModeCallRenderer(toolName) {
144
+ return (_args, theme, context) => {
145
+ const text = context.lastComponent instanceof PiTextComponent
146
+ ? context.lastComponent
147
+ : new PiTextComponent();
148
+ text.setText(theme.fg("toolTitle", theme.bold(toolName)));
149
+ return text;
150
+ };
151
+ }
152
+ function createContextModeResultRenderer(toolName) {
153
+ return (result, { expanded, isPartial }, theme, context) => {
154
+ const text = context.lastComponent instanceof PiTextComponent
155
+ ? context.lastComponent
156
+ : new PiTextComponent();
157
+ if (isPartial) {
158
+ text.setText(theme.fg("warning", "indexing/searching..."));
159
+ return text;
160
+ }
161
+ const output = (result.content ?? [])
162
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
163
+ .map((c) => c.text)
164
+ .join("\n");
165
+ if (expanded) {
166
+ text.setText(theme.fg("toolOutput", output));
167
+ return text;
168
+ }
169
+ const firstLine = output
170
+ .split(/\r?\n/)
171
+ .find((line) => line.trim().length > 0)
172
+ ?.trim();
173
+ const status = firstLine && firstLine.length <= 180
174
+ ? firstLine
175
+ : `${toolName} completed`;
176
+ text.setText(theme.fg("toolOutput", status));
177
+ return text;
178
+ };
179
+ }
99
180
  /**
100
181
  * Minimal stdio JSON-RPC client targeting the context-mode MCP server.
101
182
  *
@@ -118,6 +199,15 @@ export class MCPStdioClient {
118
199
  buffer = "";
119
200
  initialized = false;
120
201
  exited = false;
202
+ /**
203
+ * In-flight respawn promise — set while {@link respawn} runs so
204
+ * concurrent callers awaiting `request()` after an idle exit observe
205
+ * the SAME respawn, not N parallel ones. Without this guard, two
206
+ * simultaneous `callTool` calls would each see `this.exited === true`,
207
+ * each fire their own `respawn()`, and the loser leaks an orphaned
208
+ * child process the GC cannot reach (no `.kill()` reference).
209
+ */
210
+ respawnPromise = null;
121
211
  /**
122
212
  * Live env passed to the spawned child — exposed (read-only intent)
123
213
  * so tests can pin the fork-bomb-prevention env counter (#516)
@@ -232,11 +322,31 @@ export class MCPStdioClient {
232
322
  handler.resolve(msg.result);
233
323
  }
234
324
  }
235
- request(method, params, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
325
+ async request(method, params, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
326
+ // Respawn-on-idle-exit (#583, #583-followup).
327
+ //
328
+ // Initial #583 fix patched callTool() only. The structural location is
329
+ // here: `request()` is the single chokepoint for `initialize`,
330
+ // `tools/list`, `tools/call`, and any future method. Patching at this
331
+ // layer means listTools / re-initialize paths after an idle exit also
332
+ // self-heal, not just the registered-tool happy path.
333
+ //
334
+ // Sequencing is critical: respawn() resets `exited`, `child`, and
335
+ // `buffer` BEFORE start() + initialize(). The initialize() call inside
336
+ // respawn() goes through this same request() — recursion is safe
337
+ // because by the time we re-enter, `exited` is false again. We use a
338
+ // single-flight `respawnPromise` so concurrent callers share the same
339
+ // respawn (orphan-child guard, see field comment).
340
+ if (this.exited) {
341
+ if (!this.respawnPromise) {
342
+ this.respawnPromise = this.respawn().finally(() => {
343
+ this.respawnPromise = null;
344
+ });
345
+ }
346
+ await this.respawnPromise;
347
+ }
236
348
  if (!this.child)
237
349
  throw new Error("MCP client not started");
238
- if (this.exited)
239
- return Promise.reject(new Error("MCP server has exited"));
240
350
  const id = ++this.requestId;
241
351
  return new Promise((resolve, reject) => {
242
352
  const timer = setTimeout(() => {
@@ -284,20 +394,11 @@ export class MCPStdioClient {
284
394
  return Array.isArray(result.tools) ? result.tools : [];
285
395
  }
286
396
  async callTool(name, args) {
287
- // Respawn-on-idle-exit (#583). The MCP server gained an idle
288
- // self-shutdown in 1.0.132 (#565/#568, src/lifecycle.ts). When the
289
- // Pi-spawned child exits cleanly after the idle window, Pi keeps the
290
- // tool handles registered, but the bridge client is `exited=true`
291
- // and every subsequent request would reject with
292
- // "MCP server has exited" — leaving Pi's ctx_* tools permanently
293
- // broken until the user restarts Pi.
294
- //
295
- // The structural fix is here, not in lifecycle.ts: the bridge owns
296
- // the child lifecycle, so it transparently respawns + re-initialises
297
- // the server on the next call. Restores parity with adapters whose
298
- // host MCP client respawns on EOF (Claude Code, Codex, etc.).
299
- if (this.exited)
300
- await this.respawn();
397
+ // Respawn-on-idle-exit is now handled centrally in `request()`
398
+ // (#583 follow-up). Originally patched here in #583 moving it up
399
+ // one layer covers `listTools` / `initialize` paths too, with a
400
+ // single-flight guard against orphan child processes from
401
+ // concurrent callers.
301
402
  return this.request("tools/call", { name, arguments: args ?? {} }, DEFAULT_CALL_TIMEOUT_MS);
302
403
  }
303
404
  /**
@@ -305,12 +406,23 @@ export class MCPStdioClient {
305
406
  * Resets state so a fresh `start()` + `initialize()` cycle runs, then
306
407
  * the caller's pending request flows through the new child.
307
408
  *
308
- * Internalexposed only via the public `callTool()` happy path.
409
+ * Single-flightconcurrent callers share one in-flight respawn via
410
+ * {@link respawnPromise}. Internal — only entered via {@link request}.
411
+ *
412
+ * Sequencing pinned (do not reorder without updating the regression
413
+ * test in tests/adapters/pi-mcp-bridge.test.ts):
414
+ * 1. `this.child = null` — drop stale handle
415
+ * 2. `this.buffer = ""` — discard leftover bytes from old child
416
+ * 3. `this.exited = false` — must precede `start()` + `initialize()`,
417
+ * because `request("initialize", …)`
418
+ * inside `initialize()` re-checks this
419
+ * flag and would otherwise re-enter
420
+ * respawn in an infinite loop
421
+ * 4. `this.initialized = false`
422
+ * 5. `this.start()`
423
+ * 6. `await this.initialize()` — flows through `request()` recursively
309
424
  */
310
425
  async respawn() {
311
- // Drop the dead child handle and clear stream buffer so leftover
312
- // bytes from the previous incarnation don't get parsed as JSON-RPC
313
- // for the new one. Pending map is already cleared by onExit().
314
426
  this.child = null;
315
427
  this.buffer = "";
316
428
  this.exited = false;
@@ -398,6 +510,8 @@ export async function bootstrapMCPTools(pi, serverScript, options = {}) {
398
510
  // for type inference). Empty-object fallback keeps tools that
399
511
  // declare no parameters callable.
400
512
  parameters: tool.inputSchema ?? { type: "object", properties: {} },
513
+ renderCall: createContextModeCallRenderer(tool.name),
514
+ renderResult: createContextModeResultRenderer(tool.name),
401
515
  async execute(_toolCallId, params) {
402
516
  const result = await client.callTool(tool.name, params ?? {});
403
517
  const text = (result.content ?? [])
@@ -23,7 +23,7 @@ export interface LifecycleGuardOptions {
23
23
  /**
24
24
  * Idle shutdown threshold in ms (#565). When the server has handled no
25
25
  * MCP activity for this long, `onShutdown` fires. `0` disables.
26
- * Default: env `CONTEXT_MODE_IDLE_TIMEOUT_MS`, else 15 minutes.
26
+ * Default: env `CONTEXT_MODE_IDLE_TIMEOUT_MS`, else 0 (disabled).
27
27
  * Skipped on TTY stdin (interactive dev / OpenCode ts-plugin standalone).
28
28
  *
29
29
  * Pair with the returned `recordActivity()` callback — call it on every
@@ -50,20 +50,20 @@ export interface LifecycleGuardHandle {
50
50
  /**
51
51
  * Resolve the idle-shutdown threshold (#565).
52
52
  *
53
- * OpenCode + KiloCode open a fresh MCP client per session AND per subagent
54
- * task, but never tear them down for the host's lifetime. A host alive for
55
- * a working day accumulates one stdio child per session observed live at
56
- * 26 children / 1.6 GB RSS under a single `opencode serve` parent.
53
+ * Idle shutdown is OFF by default (#592) because most hosts (Claude
54
+ * Code, Codex, editor MCP clients) keep registered tool handles after a
55
+ * clean MCP child exit and do NOT transparently respawn on the next call.
56
+ * The global 15 min default introduced in #568 solved OpenCode's child
57
+ * accumulation, but stranded ctx_* tools in Claude Code/Codex-style
58
+ * hosts once the MCP server exited cleanly while the editor stayed alive.
57
59
  *
58
- * None of the existing exit paths (ppid poll, grandparent reparent, stdin
59
- * EOF, SIGTERM) fire while the host stays alive. Idle shutdown is the
60
- * structural fix: a server with no work to do should release its memory.
60
+ * Hosts that are known to benefit from idle shutdown MUST opt in via
61
+ * CONTEXT_MODE_IDLE_TIMEOUT_MS in their MCP config. Today that is
62
+ * OpenCode/KiloCode (their configs set 900000 = 15 min). Users and test
63
+ * harnesses can also opt in explicitly with any positive integer.
61
64
  *
62
- * Default 15 min strikes a balance long enough that a paused
63
- * conversation does not pay a cold-start on every resume, short enough
64
- * that 8 hours of unused sessions do not pin GB of RAM.
65
- *
66
- * Set env to `0` to disable entirely.
65
+ * Missing or malformed env = 0 (disabled, safe default). Set env to
66
+ * `0` to disable explicitly.
67
67
  *
68
68
  * Exported for unit-testing.
69
69
  */
@@ -17,30 +17,30 @@ import { execFileSync } from "node:child_process";
17
17
  /**
18
18
  * Resolve the idle-shutdown threshold (#565).
19
19
  *
20
- * OpenCode + KiloCode open a fresh MCP client per session AND per subagent
21
- * task, but never tear them down for the host's lifetime. A host alive for
22
- * a working day accumulates one stdio child per session observed live at
23
- * 26 children / 1.6 GB RSS under a single `opencode serve` parent.
20
+ * Idle shutdown is OFF by default (#592) because most hosts (Claude
21
+ * Code, Codex, editor MCP clients) keep registered tool handles after a
22
+ * clean MCP child exit and do NOT transparently respawn on the next call.
23
+ * The global 15 min default introduced in #568 solved OpenCode's child
24
+ * accumulation, but stranded ctx_* tools in Claude Code/Codex-style
25
+ * hosts once the MCP server exited cleanly while the editor stayed alive.
24
26
  *
25
- * None of the existing exit paths (ppid poll, grandparent reparent, stdin
26
- * EOF, SIGTERM) fire while the host stays alive. Idle shutdown is the
27
- * structural fix: a server with no work to do should release its memory.
27
+ * Hosts that are known to benefit from idle shutdown MUST opt in via
28
+ * CONTEXT_MODE_IDLE_TIMEOUT_MS in their MCP config. Today that is
29
+ * OpenCode/KiloCode (their configs set 900000 = 15 min). Users and test
30
+ * harnesses can also opt in explicitly with any positive integer.
28
31
  *
29
- * Default 15 min strikes a balance long enough that a paused
30
- * conversation does not pay a cold-start on every resume, short enough
31
- * that 8 hours of unused sessions do not pin GB of RAM.
32
- *
33
- * Set env to `0` to disable entirely.
32
+ * Missing or malformed env = 0 (disabled, safe default). Set env to
33
+ * `0` to disable explicitly.
34
34
  *
35
35
  * Exported for unit-testing.
36
36
  */
37
37
  export function idleTimeoutForEnv(env = process.env) {
38
38
  const raw = env.CONTEXT_MODE_IDLE_TIMEOUT_MS;
39
39
  if (raw === undefined)
40
- return 15 * 60 * 1000;
40
+ return 0;
41
41
  const n = Number.parseInt(raw, 10);
42
42
  if (!Number.isFinite(n) || n < 0)
43
- return 15 * 60 * 1000;
43
+ return 0;
44
44
  return n;
45
45
  }
46
46
  /** Read grandparent PID via `ps -o ppid= -p $PPID`. Returns NaN on failure or Windows. */