context-mode 1.0.162 → 1.0.164

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 (149) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  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 +149 -30
  7. package/bin/statusline.mjs +24 -4
  8. package/build/adapters/antigravity/index.d.ts +1 -1
  9. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  10. package/build/adapters/antigravity-cli/index.js +342 -0
  11. package/build/adapters/claude-code/hooks.d.ts +1 -0
  12. package/build/adapters/claude-code/hooks.js +3 -0
  13. package/build/adapters/claude-code/index.js +24 -5
  14. package/build/adapters/client-map.js +5 -0
  15. package/build/adapters/codex/hooks.d.ts +5 -1
  16. package/build/adapters/codex/hooks.js +5 -1
  17. package/build/adapters/codex/index.d.ts +9 -1
  18. package/build/adapters/codex/index.js +87 -5
  19. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  20. package/build/adapters/copilot-cli/hooks.js +64 -0
  21. package/build/adapters/copilot-cli/index.d.ts +48 -0
  22. package/build/adapters/copilot-cli/index.js +341 -0
  23. package/build/adapters/detect.d.ts +1 -1
  24. package/build/adapters/detect.js +71 -3
  25. package/build/adapters/openclaw/mcp-tools.js +1 -1
  26. package/build/adapters/opencode/index.js +31 -17
  27. package/build/adapters/opencode/zod3tov4.js +27 -6
  28. package/build/adapters/pi/extension.d.ts +2 -12
  29. package/build/adapters/pi/extension.js +128 -109
  30. package/build/adapters/types.d.ts +5 -4
  31. package/build/adapters/types.js +4 -3
  32. package/build/cache-heal.d.ts +48 -0
  33. package/build/cache-heal.js +150 -0
  34. package/build/cli.js +37 -97
  35. package/build/executor.d.ts +25 -0
  36. package/build/executor.js +143 -22
  37. package/build/lifecycle.d.ts +48 -0
  38. package/build/lifecycle.js +111 -0
  39. package/build/opencode-plugin.js +5 -2
  40. package/build/routing-block.d.ts +8 -0
  41. package/build/routing-block.js +86 -0
  42. package/build/runtime.d.ts +0 -36
  43. package/build/runtime.js +107 -27
  44. package/build/search/flood-guard.d.ts +57 -0
  45. package/build/search/flood-guard.js +80 -0
  46. package/build/security.d.ts +73 -3
  47. package/build/security.js +293 -33
  48. package/build/server.d.ts +14 -0
  49. package/build/server.js +441 -354
  50. package/build/session/analytics.d.ts +1 -1
  51. package/build/session/analytics.js +5 -1
  52. package/build/session/db.js +23 -3
  53. package/build/session/extract.js +78 -0
  54. package/build/store.d.ts +1 -1
  55. package/build/store.js +139 -25
  56. package/build/tool-naming.d.ts +4 -0
  57. package/build/tool-naming.js +24 -0
  58. package/build/util/jsonc.d.ts +14 -0
  59. package/build/util/jsonc.js +104 -0
  60. package/cli.bundle.mjs +253 -250
  61. package/configs/antigravity/GEMINI.md +2 -2
  62. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  63. package/configs/antigravity-cli/hooks.json +37 -0
  64. package/configs/antigravity-cli/mcp_config.json +10 -0
  65. package/configs/antigravity-cli/plugin.json +14 -0
  66. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  67. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  68. package/configs/claude-code/CLAUDE.md +2 -2
  69. package/configs/codex/AGENTS.md +2 -2
  70. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  71. package/configs/copilot-cli/.mcp.json +12 -0
  72. package/configs/copilot-cli/README.md +47 -0
  73. package/configs/copilot-cli/hooks.json +41 -0
  74. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  75. package/configs/gemini-cli/GEMINI.md +2 -2
  76. package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
  77. package/configs/kilo/AGENTS.md +2 -2
  78. package/configs/kiro/KIRO.md +2 -2
  79. package/configs/omp/SYSTEM.md +2 -2
  80. package/configs/openclaw/AGENTS.md +2 -2
  81. package/configs/opencode/AGENTS.md +2 -2
  82. package/configs/qwen-code/QWEN.md +2 -2
  83. package/configs/vscode-copilot/copilot-instructions.md +2 -2
  84. package/configs/zed/AGENTS.md +2 -2
  85. package/hooks/antigravity-cli/payload.mjs +98 -0
  86. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  87. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  88. package/hooks/antigravity-cli/stop.mjs +58 -0
  89. package/hooks/codex/pretooluse.mjs +14 -4
  90. package/hooks/codex/stop.mjs +12 -4
  91. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  92. package/hooks/copilot-cli/precompact.mjs +66 -0
  93. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  94. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  95. package/hooks/copilot-cli/stop.mjs +59 -0
  96. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  97. package/hooks/core/codex-caps.mjs +112 -0
  98. package/hooks/core/formatters.mjs +158 -7
  99. package/hooks/core/mcp-ready.mjs +37 -8
  100. package/hooks/core/routing.mjs +94 -8
  101. package/hooks/core/tool-naming.mjs +3 -0
  102. package/hooks/hooks.json +12 -1
  103. package/hooks/pretooluse.mjs +6 -2
  104. package/hooks/routing-block.mjs +3 -4
  105. package/hooks/security.bundle.mjs +2 -1
  106. package/hooks/session-db.bundle.mjs +5 -5
  107. package/hooks/session-directive.mjs +88 -20
  108. package/hooks/session-extract.bundle.mjs +2 -2
  109. package/hooks/session-helpers.mjs +21 -0
  110. package/hooks/sessionstart.mjs +37 -5
  111. package/hooks/stop.mjs +49 -0
  112. package/openclaw.plugin.json +1 -1
  113. package/package.json +2 -10
  114. package/server.bundle.mjs +206 -200
  115. package/skills/ctx-insight/SKILL.md +12 -17
  116. package/build/util/db-lock.d.ts +0 -65
  117. package/build/util/db-lock.js +0 -166
  118. package/insight/index.html +0 -13
  119. package/insight/package.json +0 -55
  120. package/insight/server.mjs +0 -1265
  121. package/insight/src/components/analytics.tsx +0 -112
  122. package/insight/src/components/ui/badge.tsx +0 -52
  123. package/insight/src/components/ui/button.tsx +0 -58
  124. package/insight/src/components/ui/card.tsx +0 -103
  125. package/insight/src/components/ui/chart.tsx +0 -371
  126. package/insight/src/components/ui/collapsible.tsx +0 -19
  127. package/insight/src/components/ui/input.tsx +0 -20
  128. package/insight/src/components/ui/progress.tsx +0 -83
  129. package/insight/src/components/ui/scroll-area.tsx +0 -55
  130. package/insight/src/components/ui/separator.tsx +0 -23
  131. package/insight/src/components/ui/table.tsx +0 -114
  132. package/insight/src/components/ui/tabs.tsx +0 -82
  133. package/insight/src/components/ui/tooltip.tsx +0 -64
  134. package/insight/src/lib/api.ts +0 -144
  135. package/insight/src/lib/utils.ts +0 -6
  136. package/insight/src/main.tsx +0 -22
  137. package/insight/src/routeTree.gen.ts +0 -189
  138. package/insight/src/router.tsx +0 -19
  139. package/insight/src/routes/__root.tsx +0 -55
  140. package/insight/src/routes/enterprise.tsx +0 -316
  141. package/insight/src/routes/index.tsx +0 -1482
  142. package/insight/src/routes/knowledge.tsx +0 -221
  143. package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
  144. package/insight/src/routes/search.tsx +0 -97
  145. package/insight/src/routes/sessions.tsx +0 -179
  146. package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
  147. package/insight/src/styles.css +0 -104
  148. package/insight/tsconfig.json +0 -29
  149. package/insight/vite.config.ts +0 -19
@@ -11,6 +11,12 @@
11
11
  * next poll tick), which closes the multi-day CPU-spin window seen in
12
12
  * #311/#388 without reintroducing the false-positive shutdowns of #236.
13
13
  *
14
+ * Additionally, for MCP BRIDGE CHILDREN only (CONTEXT_MODE_BRIDGE_DEPTH>0), a
15
+ * request-idle self-shutdown reaps a child that a pi/omp sub-context abandoned
16
+ * while its long-lived parent keeps running (#854) — gated so the depth-0
17
+ * keep-alive servers #602 restored are never reaped, never via stdin EOF, and
18
+ * never while a tool call is in flight (#643).
19
+ *
14
20
  * Cross-platform: macOS, Linux, Windows.
15
21
  */
16
22
  import { execFileSync } from "node:child_process";
@@ -94,6 +100,80 @@ export function lifecycleGuardIntervalForEnv(env = process.env) {
94
100
  return 30_000;
95
101
  return 1000;
96
102
  }
103
+ /**
104
+ * #854: idle-shutdown timeout (ms) for an MCP BRIDGE CHILD. Returns 0 (disabled)
105
+ * unless this process is a bridge child (CONTEXT_MODE_BRIDGE_DEPTH>0). depth-0 /
106
+ * absent always returns 0, so the long-lived keep-alive servers that #602
107
+ * restored are NEVER reaped on idle. Default for bridge children is 3 min;
108
+ * override with CONTEXT_MODE_BRIDGE_IDLE_MS (a non-positive value disables it).
109
+ * The reaper additionally never fires while a tool call is in flight (see
110
+ * {@link noteRequestStart}), so the window only bounds how fast *abandoned*
111
+ * children drain — it does not cap legitimate long-running calls.
112
+ *
113
+ * Exported for unit-testing.
114
+ */
115
+ export function bridgeChildIdleTimeoutMs(env = process.env) {
116
+ const depth = Number.parseInt(env.CONTEXT_MODE_BRIDGE_DEPTH ?? "", 10);
117
+ if (!Number.isFinite(depth) || depth <= 0)
118
+ return 0;
119
+ const raw = env.CONTEXT_MODE_BRIDGE_IDLE_MS;
120
+ if (raw !== undefined) {
121
+ const v = Number.parseInt(raw, 10);
122
+ return Number.isFinite(v) && v > 0 ? v : 0;
123
+ }
124
+ return 180_000;
125
+ }
126
+ // #854 idle-reaper state, module-level by design: an MCP server is exactly one
127
+ // process (one StdioServerTransport + one lifecycle guard), so these are never
128
+ // shared across concurrent servers in production. Multiple startLifecycleGuard()
129
+ // instances arise only in tests, which pair/reset these explicitly.
130
+ /** Last MCP activity timestamp (inbound message, tool-call start/end, or response). */
131
+ let _lastMcpActivity = Date.now();
132
+ /** In-flight tool-call count — the reaper never fires while this is > 0. */
133
+ let _inFlight = 0;
134
+ /**
135
+ * #854: record MCP activity (inbound message or response). The server calls this
136
+ * so the bridge-child idle reaper in {@link startLifecycleGuard} can distinguish
137
+ * an actively-used child from an abandoned one. Cheap; safe on the hot path.
138
+ */
139
+ export function noteMcpActivity() {
140
+ _lastMcpActivity = Date.now();
141
+ }
142
+ /**
143
+ * #854: mark a tool call as started. Suppresses the bridge-child idle reaper so a
144
+ * single long-running ctx_execute / ctx_batch_execute (which sends one inbound
145
+ * frame then runs unbounded, #643) is never reaped mid-execution.
146
+ */
147
+ export function noteRequestStart() {
148
+ _inFlight++;
149
+ _lastMcpActivity = Date.now();
150
+ }
151
+ /** #854: mark a tool call as finished (success or error). */
152
+ export function noteRequestEnd() {
153
+ if (_inFlight > 0)
154
+ _inFlight--;
155
+ _lastMcpActivity = Date.now();
156
+ }
157
+ /**
158
+ * #854: wrap an MCP stdio transport's `onmessage` so each inbound message
159
+ * refreshes the idle clock. Best-effort: call after `connect()` (onmessage set);
160
+ * a no-op if it isn't a function, and a throw in noteMcpActivity never breaks
161
+ * dispatch. No stdin touch (preserves the #236 contract). Exported for testing.
162
+ */
163
+ export function attachMcpActivityTap(transport) {
164
+ if (!transport)
165
+ return;
166
+ const prev = typeof transport.onmessage === "function" ? transport.onmessage.bind(transport) : null;
167
+ if (!prev)
168
+ return;
169
+ transport.onmessage = (message, extra) => {
170
+ try {
171
+ noteMcpActivity();
172
+ }
173
+ catch { /* never break message dispatch */ }
174
+ return prev(message, extra);
175
+ };
176
+ }
97
177
  /**
98
178
  * Start the lifecycle guard. Returns a cleanup function.
99
179
  * Skipped automatically when stdin is a TTY (e.g. OpenCode ts-plugin).
@@ -142,9 +222,40 @@ export function startLifecycleGuard(opts) {
142
222
  if (!process.stdin.isTTY) {
143
223
  process.stdin.on("end", onStdinEnd);
144
224
  }
225
+ // #854: request-idle self-shutdown for MCP BRIDGE CHILDREN only
226
+ // (CONTEXT_MODE_BRIDGE_DEPTH>0). Pi/omp loads the extension once per
227
+ // sub-context and spawns one bridge child each, tearing them down only at
228
+ // session_shutdown — which never fires for sub-contexts while the long-lived
229
+ // parent stays alive, so idle children accumulate (#854, same class as #565).
230
+ // A bridge child that receives no inbound MCP message for `idleMs` exits
231
+ // itself; the extension's single-flight path respawns one on the next call.
232
+ //
233
+ // Scoped strictly to depth>0 so the depth-0 keep-alive servers that #602
234
+ // restored are never reaped on idle. The trigger is idle TIME via
235
+ // noteMcpActivity() (NOT stdin EOF), so the #236 contract — and lifecycle's
236
+ // hands-off-stdin invariant — are untouched.
237
+ const idleMs = opts.bridgeIdleMs ?? bridgeChildIdleTimeoutMs();
238
+ let idleTimer;
239
+ if (idleMs > 0) {
240
+ _lastMcpActivity = Date.now();
241
+ idleTimer = setInterval(() => {
242
+ // Reap only when truly quiescent: NO tool call in flight AND no MCP
243
+ // activity for `idleMs`. The in-flight guard prevents reaping a child
244
+ // mid-execution during a long single ctx_execute/batch that sends no
245
+ // further messages (#643 unbounded calls) — the false-reap regression the
246
+ // adversarial review flagged.
247
+ if (_inFlight === 0 && Date.now() - _lastMcpActivity >= idleMs) {
248
+ process.stderr.write(`[context-mode] idle MCP bridge child self-shutdown after ${idleMs}ms with no activity (#854)\n`);
249
+ shutdown();
250
+ }
251
+ }, Math.max(1000, Math.min(Math.floor(idleMs / 4), 30_000)));
252
+ idleTimer.unref();
253
+ }
145
254
  return () => {
146
255
  stopped = true;
147
256
  clearInterval(timer);
257
+ if (idleTimer)
258
+ clearInterval(idleTimer);
148
259
  for (const sig of signals)
149
260
  process.removeListener(sig, shutdown);
150
261
  process.stdin.removeListener("end", onStdinEnd);
@@ -179,7 +179,7 @@ async function createContextModePlugin(ctx) {
179
179
  const toolInput = output.args ?? {};
180
180
  let decision;
181
181
  try {
182
- decision = routing.routePreToolUse(toolName, toolInput, projectDir, getPlatform());
182
+ decision = routing.routePreToolUse(toolName, toolInput, projectDir, platform);
183
183
  }
184
184
  catch {
185
185
  return; // Routing failure → allow passthrough
@@ -194,7 +194,10 @@ async function createContextModePlugin(ctx) {
194
194
  // Mutate output.args — OpenCode reads the mutated output object
195
195
  Object.assign(output.args, decision.updatedInput);
196
196
  }
197
- // "context" action no-op (OpenCode doesn't support context injection)
197
+ if (decision.action === "context" && decision.additionalContext) {
198
+ // Mutate output.args — OpenCode reads the mutated output object
199
+ output.args.additionalContext = decision.additionalContext;
200
+ }
198
201
  },
199
202
  // ── PostToolUse: Session event capture ──────────────
200
203
  "tool.execute.after": async (input, output) => {
@@ -0,0 +1,8 @@
1
+ import type { ToolNamer } from "./tool-naming.js";
2
+ export interface RoutingBlockOptions {
3
+ includeCommands?: boolean;
4
+ }
5
+ export declare function createRoutingBlock(t: ToolNamer, options?: RoutingBlockOptions): string;
6
+ export declare function createReadGuidance(t: ToolNamer): string;
7
+ export declare function createGrepGuidance(t: ToolNamer): string;
8
+ export declare function createBashGuidance(t: ToolNamer): string;
@@ -0,0 +1,86 @@
1
+ export function createRoutingBlock(t, options = {}) {
2
+ const { includeCommands = true } = options;
3
+ return `
4
+ <context_window_protection>
5
+ <priority_instructions>
6
+ Raw tool output floods context window. MUST use context-mode MCP tools. Keep raw data in sandbox.
7
+ </priority_instructions>
8
+
9
+ <tool_selection_hierarchy>
10
+ 0. MEMORY: ${t("ctx_search")}(sort: "timeline")
11
+ - After resume, check prior context before asking user.
12
+ 1. GATHER: ${t("ctx_batch_execute")}(commands, queries)
13
+ - Primary research tool. Runs commands, auto-indexes, searches. ONE call replaces many steps.
14
+ - Each command: {label: "section header", command: "shell command"}
15
+ - label becomes FTS5 chunk title — descriptive labels improve search.
16
+ 2. FOLLOW-UP: ${t("ctx_search")}(queries: ["q1", "q2", ...])
17
+ - All follow-up questions. ONE call, many queries (default relevance mode).
18
+ 3. PROCESSING: ${t("ctx_execute")}(language, code) | ${t("ctx_execute_file")}(path, language, code)
19
+ - API calls, log analysis, data processing.
20
+ </tool_selection_hierarchy>
21
+
22
+ <forbidden_actions>
23
+ - NO Bash for commands producing >20 lines output.
24
+ - NO Read for analysis — use execute_file. Read IS correct for files you intend to Edit.
25
+ - NO WebFetch — use ${t("ctx_fetch_and_index")}.
26
+ - Bash ONLY for git/mkdir/rm/mv/navigation.
27
+ - NO ${t("ctx_execute")} or ${t("ctx_execute_file")} for file creation/modification.
28
+ ctx_execute is for analysis, processing, computation only.
29
+ </forbidden_actions>
30
+
31
+ <file_writing_policy>
32
+ ALWAYS use native Write/Edit tools for file creation/modification.
33
+ NEVER use ${t("ctx_execute")}, ${t("ctx_execute_file")}, or Bash to write files.
34
+ Applies to all file types: code, configs, plans, specs, YAML, JSON, markdown.
35
+ </file_writing_policy>
36
+
37
+ <output_constraints>
38
+ <communication_style>
39
+ Terse like caveman. Technical substance exact. Only fluff die.
40
+ Use fragments when clear. Short synonyms (fix not "implement a solution for").
41
+ Technical terms exact. Code blocks unchanged.
42
+ Auto-expand for: security warnings, irreversible actions, user confusion.
43
+ </communication_style>
44
+ <artifact_policy>
45
+ Write artifacts (code, configs, PRDs) to FILES. NEVER inline.
46
+ Return only: file path + 1-line description.
47
+ </artifact_policy>
48
+ <response_format>
49
+ Concise summary:
50
+ - Actions taken (2-3 bullets)
51
+ - File paths created/modified
52
+ - Key findings
53
+ </response_format>
54
+ </output_constraints>
55
+ <session_continuity>
56
+ Skills, roles, and decisions set during this session remain active until the user revokes them.
57
+ Do not drop behavioral directives as context grows.
58
+ </session_continuity>
59
+ ${includeCommands ? `
60
+ <ctx_commands>
61
+ "ctx stats" | "ctx-stats" | "/ctx-stats" | context savings question
62
+ → Call stats MCP tool, display full output verbatim.
63
+
64
+ "ctx doctor" | "ctx-doctor" | "/ctx-doctor" | diagnose context-mode
65
+ → Call doctor MCP tool, run returned shell command, display as checklist.
66
+
67
+ "ctx upgrade" | "ctx-upgrade" | "/ctx-upgrade" | update context-mode
68
+ → Call upgrade MCP tool, run returned shell command, display as checklist.
69
+
70
+ "ctx purge" | "ctx-purge" | "/ctx-purge" | wipe/reset knowledge base
71
+ → Call purge MCP tool with confirm: true. Warn: irreversible.
72
+
73
+ After /clear or /compact: knowledge base preserved. Tell user: "context-mode knowledge base preserved. Use \`ctx purge\` to start fresh."
74
+ </ctx_commands>
75
+ ` : ''}
76
+ </context_window_protection>`;
77
+ }
78
+ export function createReadGuidance(t) {
79
+ return '<context_guidance>\n <tip>\n Reading to Edit? Read is correct — Edit needs content in context.\n Reading to analyze/explore? Use ' + t("ctx_execute_file") + '(path, language, code) — only printed summary enters context.\n </tip>\n</context_guidance>';
80
+ }
81
+ export function createGrepGuidance(t) {
82
+ return '<context_guidance>\n <tip>\n May flood context. Use ' + t("ctx_execute") + '(language: "shell", code: "...") to run searches in sandbox. Only printed summary enters context.\n </tip>\n</context_guidance>';
83
+ }
84
+ export function createBashGuidance(t) {
85
+ return '<context_guidance>\n <tip>\n May produce large output. Use ' + t("ctx_batch_execute") + '(commands, queries) for multiple commands, ' + t("ctx_execute") + '(language: "shell", code: "...") for single. Only printed summary enters context. Bash only for: git, mkdir, rm, mv, navigation.\n </tip>\n</context_guidance>';
86
+ }
@@ -65,42 +65,6 @@ export interface HookRuntime {
65
65
  * mask the mock and yield the host's real bun/node detection result.
66
66
  */
67
67
  export declare function resetHookRuntimeCache(): void;
68
- /**
69
- * Resolve the JS runtime to use for spawning hook scripts (issue #738).
70
- *
71
- * Returns Bun when:
72
- * - a bun binary is located via {@link bunCommand} (already handles the
73
- * Windows .cmd shim trap from #506 + absolute path fallbacks), AND
74
- * - `bun --version` exits 0 within the probe timeout, AND
75
- * - the reported semver major is ≥1.
76
- *
77
- * Returns Node (`process.execPath`) on every other path — missing bun,
78
- * version probe failure, version <1, malformed version banner. Silent
79
- * fallback: never throws, never logs to stderr (a noisy log would clutter
80
- * the same MCP boot output that #719 tightened up).
81
- *
82
- * Result is cached at module load so the cost is amortised across every
83
- * hook command emission for the lifetime of the process. The cache also
84
- * keeps the behaviour deterministic — if the user `brew uninstall bun`
85
- * mid-session, the cached resolution stays valid for that session and the
86
- * next MCP boot re-detects.
87
- *
88
- * Why bun ≥1.0 instead of "any bun":
89
- * - Bun 0.x had multiple ESM/module-resolution regressions that broke
90
- * dynamic `import()` inside hooks (and our hooks do ~7 dynamic imports
91
- * in `pretooluse.mjs`).
92
- * - 1.0 ships stable npm-compat that our better-sqlite3-adjacent code
93
- * relies on indirectly (hooks share `ensure-deps.mjs` which is
94
- * bun-safe past 1.0 but not 0.x).
95
- *
96
- * NOT used by:
97
- * - `buildNodeCommand` — kept on `process.execPath` for openclaw doctor /
98
- * upgrade hints which must invoke the better-sqlite3-loading CLI on
99
- * Node (#543: bun cannot dlopen better-sqlite3's prebuilt .node).
100
- * - `ensure-deps.mjs` — separate path, must stay on Node for the same
101
- * reason.
102
- * - `ctx_upgrade` — separate path, must stay on Node for the same reason.
103
- */
104
68
  export declare function resolveHookRuntime(): HookRuntime;
105
69
  export declare function getRuntimeSummary(runtimes: RuntimeMap): string;
106
70
  export declare function getAvailableLanguages(runtimes: RuntimeMap): Language[];
package/build/runtime.js CHANGED
@@ -27,6 +27,10 @@ function isWindowsWslBash(shellPath) {
27
27
  return /\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(lower) ||
28
28
  /\\microsoft\\windowsapps\\bash\.exe$/.test(lower);
29
29
  }
30
+ function isWindowsSystemCmd(shellPath) {
31
+ const lower = shellPath.toLowerCase().replace(/\//g, "\\");
32
+ return /\\windows\\(?:system32|sysnative)\\cmd\.exe$/.test(lower);
33
+ }
30
34
  const isWindows = process.platform === "win32";
31
35
  function commandExists(cmd) {
32
36
  try {
@@ -130,38 +134,69 @@ function bunFallbackPaths() {
130
134
  }
131
135
  return home ? [`${home}/.bun/bin/bun`] : [];
132
136
  }
137
+ /** Well-known Git-for-Windows bash.exe locations (MSYS bash that performs
138
+ * Windows→POSIX path conversion for native git — #826). */
139
+ const KNOWN_GIT_BASH_PATHS = [
140
+ "C:\\Program Files\\Git\\usr\\bin\\bash.exe",
141
+ "C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe",
142
+ ];
133
143
  /**
134
- * On Windows, resolve the first non-WSL bash in PATH.
135
- * WSL bash (C:\Windows\System32\bash.exe) cannot handle Windows paths,
136
- * so we skip it and prefer Git Bash or MSYS2 bash instead.
144
+ * On Windows, resolve the first non-WSL bash that is actually available.
145
+ *
146
+ * Availability is gated by `where bash` (#796): bash must be discoverable on
147
+ * PATH for us to claim it. WSL bash (C:\Windows\System32\bash.exe) cannot
148
+ * handle Windows paths, so we skip it and prefer Git Bash / MSYS2 bash.
149
+ *
150
+ * Routing the gate through `where bash` — rather than probing the known Git
151
+ * Bash paths with existsSync first — is deliberate: when bash is genuinely
152
+ * unavailable, the caller must fall through to pwsh (PR intent). Probing the
153
+ * filesystem first re-detected a real Git Bash on the runner even though the
154
+ * scenario was "bash unavailable", so pwsh was never reached.
155
+ *
156
+ * #826 is preserved: when `where bash` surfaces a Git Bash candidate we
157
+ * canonicalize it to the absolute Git\usr\bin\bash.exe path (so native git
158
+ * keeps MSYS path conversion) by preferring a matching known path that exists.
137
159
  */
138
160
  function resolveWindowsBash() {
139
- // First, try well-known Git Bash locations directly (works even when
140
- // Git\usr\bin is not on PATH, which is common in MCP server environments
141
- // that only inherit Git\cmd from the system PATH).
142
- const knownPaths = [
143
- "C:\\Program Files\\Git\\usr\\bin\\bash.exe",
144
- "C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe",
145
- ];
146
- for (const p of knownPaths) {
147
- if (existsSync(p))
148
- return p;
149
- }
150
- // Fallback: scan PATH via `where bash`, skipping WSL and WindowsApps entries.
161
+ let candidates;
151
162
  try {
152
163
  const result = execSync("where bash", { encoding: "utf-8", stdio: "pipe" });
153
- const candidates = result.trim().split(/\r?\n/).map(p => p.trim()).filter(Boolean);
154
- for (const p of candidates) {
155
- const lower = p.toLowerCase();
156
- if (lower.includes("system32") || lower.includes("windowsapps"))
157
- continue;
158
- return p;
159
- }
160
- return null;
164
+ candidates = result.trim().split(/\r?\n/).map(p => p.trim()).filter(Boolean);
161
165
  }
162
166
  catch {
167
+ // bash not on PATH → genuinely unavailable. Fall through to pwsh/etc.
163
168
  return null;
164
169
  }
170
+ for (const p of candidates) {
171
+ const lower = p.toLowerCase();
172
+ if (lower.includes("system32") || lower.includes("windowsapps"))
173
+ continue;
174
+ // Prefer the canonical Git\usr\bin\bash.exe so native git retains MSYS
175
+ // path conversion. `where bash` on a Git-for-Windows install may surface
176
+ // the Git\cmd\bash shim or usr\bin path; upgrade to a known absolute path
177
+ // when one exists on disk.
178
+ for (const known of KNOWN_GIT_BASH_PATHS) {
179
+ if (existsSync(known))
180
+ return known;
181
+ }
182
+ return p;
183
+ }
184
+ return null;
185
+ }
186
+ function resolveWindowsShell(windowsBash = resolveWindowsBash()) {
187
+ // Prefer Git Bash (#826) so native git keeps its MSYS path conversion.
188
+ // The caller passes the already-resolved windowsBash to avoid probing the
189
+ // filesystem twice (it also feeds the cmd.exe shellOverride guard above).
190
+ // Fall back through POSIX sh, then PowerShell Core (pwsh) for proper UTF-8
191
+ // handling, then Windows PowerShell, then cmd.exe as the last resort.
192
+ return windowsBash
193
+ ?? (commandExists("sh")
194
+ ? "sh"
195
+ : commandExists("pwsh")
196
+ ? "pwsh"
197
+ : commandExists("powershell")
198
+ ? "powershell"
199
+ : "cmd.exe");
165
200
  }
166
201
  function getVersion(cmd, args = ["--version"]) {
167
202
  try {
@@ -231,7 +266,17 @@ export function resolveJavascriptRuntime(bun, deps = {}) {
231
266
  if (JS_RUNTIMES.has(base)) {
232
267
  // Real JS runtime (node, bun, deno) — preserves #190 snap-Node fix
233
268
  // because the snap wrapper's binary is literally named `node`.
234
- return execPath;
269
+ //
270
+ // Issue #800 — liveness guard: on Homebrew, process.execPath points into
271
+ // the versioned Cellar (/opt/homebrew/Cellar/node/26.0.0/bin/node).
272
+ // `brew upgrade` + `brew cleanup` deletes the old Cellar, so the path
273
+ // dangles for the life of the already-running MCP server. If the path
274
+ // doesn't exist on disk, skip it and fall through to PATH node.
275
+ if (existsSync(execPath)) {
276
+ return execPath;
277
+ }
278
+ // Stale execPath (deleted Cellar, corrupted install, uninstall while
279
+ // process alive). Fall through to PATH resolution below.
235
280
  }
236
281
  // Host binary (opencode/kilo/etc.) — fall back to node on PATH.
237
282
  if (cmdExists("node"))
@@ -252,10 +297,15 @@ export function detectRuntimes() {
252
297
  // could redirect the executor to /usr/bin/python or any arbitrary binary.
253
298
  const userShell = process.env.SHELL;
254
299
  const isWin = process.platform === "win32";
300
+ const windowsBash = isWin ? resolveWindowsBash() : null;
255
301
  const shellOverride = userShell &&
256
302
  existsSync(userShell) &&
257
303
  isAllowlistedShell(userShell) &&
258
- !(isWin && isWindowsWslBash(userShell))
304
+ !(isWin && isWindowsWslBash(userShell)) &&
305
+ // Windows OpenSSH can inject the system cmd.exe as ambient SHELL. When
306
+ // Git Bash is installed, treating that as an explicit override breaks the
307
+ // POSIX shell executor path restored by #36/#384/#791.
308
+ !(isWin && windowsBash && isWindowsSystemCmd(userShell))
259
309
  ? userShell
260
310
  : null;
261
311
  return {
@@ -275,7 +325,7 @@ export function detectRuntimes() {
275
325
  ? "py"
276
326
  : null,
277
327
  shell: shellOverride ?? (isWin
278
- ? (resolveWindowsBash() ?? (commandExists("sh") ? "sh" : commandExists("powershell") ? "powershell" : "cmd.exe"))
328
+ ? resolveWindowsShell(windowsBash)
279
329
  : commandExists("bash") ? "bash" : "sh"),
280
330
  ruby: commandExists("ruby") ? "ruby" : null,
281
331
  go: commandExists("go") ? "go" : null,
@@ -360,10 +410,40 @@ function bunVersionAtLeast1(versionOutput) {
360
410
  * reason.
361
411
  * - `ctx_upgrade` — separate path, must stay on Node for the same reason.
362
412
  */
413
+ /**
414
+ * Liveness-guarded Node path for the hook-runtime fallback (issue #841).
415
+ *
416
+ * `process.execPath` is pinned into every baked hook command because PATH
417
+ * resolution is unreliable for hooks (#190 snap-Node re-invokes the wrapper;
418
+ * #369 Windows Git Bash / MSYS can't resolve a bare `node`). But under a
419
+ * version manager (mise / asdf / nvm) execPath is a *version-pinned* absolute
420
+ * path — e.g. `~/.local/share/mise/installs/node/20.1.0/bin/node`. A routine
421
+ * `mise upgrade node` installs the next patch and DELETES the 20.1.0 dir, so
422
+ * the cached path dangles and every hook spawn fails with ENOENT — silently
423
+ * killing context-mode for that user.
424
+ *
425
+ * Same liveness-guard shape as the #800/#803 fix in
426
+ * {@link resolveJavascriptRuntime}: use the pinned execPath IFF it still
427
+ * exists on disk (preserving the #190/#369 reasons it was pinned), otherwise
428
+ * re-resolve a working `node` from PATH. The version manager's shim dir is on
429
+ * PATH and always points at the current patch, so bare `node` heals the host
430
+ * without a re-install. Falls back to the (stale) execPath only when no PATH
431
+ * node is reachable either — a strictly-better last resort than a dangling
432
+ * versioned path, and the doctor/upgrade flows surface the actionable error.
433
+ */
434
+ function liveNodeRuntime() {
435
+ if (existsSync(process.execPath)) {
436
+ return { path: process.execPath, isBun: false };
437
+ }
438
+ if (commandExists("node")) {
439
+ return { path: "node", isBun: false };
440
+ }
441
+ return { path: process.execPath, isBun: false };
442
+ }
363
443
  export function resolveHookRuntime() {
364
444
  if (_hookRuntimeCache)
365
445
  return _hookRuntimeCache;
366
- const nodeFallback = { path: process.execPath, isBun: false };
446
+ const nodeFallback = liveNodeRuntime();
367
447
  try {
368
448
  if (!bunExists()) {
369
449
  _hookRuntimeCache = nodeFallback;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * ctx_search flood-guard — per-agent-context progressive throttle.
3
+ *
4
+ * Background (#79 / #155 / #697): ctx_search carries a progressive throttle
5
+ * so a single actor cannot spam dozens of individual searches and flood the
6
+ * context window instead of batching via ctx_batch_execute. The original
7
+ * implementation kept ONE module-global counter on the MCP server process.
8
+ *
9
+ * Issue #769: a parallel multi-agent fan-out (Claude Code Task/Workflow)
10
+ * runs N subagents concurrently against the SAME per-session MCP server
11
+ * process. With a single global counter their independent calls are summed
12
+ * into one budget, so legitimate fan-out ("10 agents x 2 calls") trips the
13
+ * guard that was only ever meant to catch ONE actor spamming. The budget is
14
+ * tool-availability state that is logically per-agent-context, so the counter
15
+ * must be keyed per agent-context — NOT removed. Single-actor flood
16
+ * protection is preserved exactly; only the bucketing changes.
17
+ *
18
+ * This module is pure and transport-free so the policy is unit-testable
19
+ * without spinning up the MCP server. `src/server.ts` owns the singleton and
20
+ * supplies the per-call agent key (the session/agent id from
21
+ * currentAttribution()).
22
+ */
23
+ export interface FloodGuardConfig {
24
+ /** Rolling window length in ms. After this elapses a key's counter resets. */
25
+ windowMs: number;
26
+ /** After this many calls in the window, results taper to 1 per query. */
27
+ softCapAfter: number;
28
+ /** After this many calls in the window, the call is hard-blocked. */
29
+ blockAfter: number;
30
+ }
31
+ export interface FloodDecision {
32
+ /** This key's call count within the current rolling window (1-based). */
33
+ count: number;
34
+ /** Window start timestamp (ms) for this key — used for the "in Ns" message. */
35
+ windowStart: number;
36
+ /** True once count exceeds blockAfter — caller must refuse the search. */
37
+ blocked: boolean;
38
+ /** True once count exceeds softCapAfter — caller trims to 1 result/query. */
39
+ softCapped: boolean;
40
+ }
41
+ /**
42
+ * A rolling-window call counter bucketed per agent-context key. Each key gets
43
+ * an independent window + counter, so concurrent subagents do not consume one
44
+ * another's budget while a single greedy actor is still throttled and blocked
45
+ * exactly as before.
46
+ */
47
+ export declare class FloodGuard {
48
+ #private;
49
+ constructor(cfg: FloodGuardConfig, maxKeys?: number);
50
+ /**
51
+ * Record one ctx_search call for `key` at time `now` (ms) and return the
52
+ * throttle decision. Pure aside from the internal per-key counter state.
53
+ */
54
+ record(key: string, now?: number): FloodDecision;
55
+ /** Test/diagnostics helper — number of distinct keys currently tracked. */
56
+ size(): number;
57
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ctx_search flood-guard — per-agent-context progressive throttle.
3
+ *
4
+ * Background (#79 / #155 / #697): ctx_search carries a progressive throttle
5
+ * so a single actor cannot spam dozens of individual searches and flood the
6
+ * context window instead of batching via ctx_batch_execute. The original
7
+ * implementation kept ONE module-global counter on the MCP server process.
8
+ *
9
+ * Issue #769: a parallel multi-agent fan-out (Claude Code Task/Workflow)
10
+ * runs N subagents concurrently against the SAME per-session MCP server
11
+ * process. With a single global counter their independent calls are summed
12
+ * into one budget, so legitimate fan-out ("10 agents x 2 calls") trips the
13
+ * guard that was only ever meant to catch ONE actor spamming. The budget is
14
+ * tool-availability state that is logically per-agent-context, so the counter
15
+ * must be keyed per agent-context — NOT removed. Single-actor flood
16
+ * protection is preserved exactly; only the bucketing changes.
17
+ *
18
+ * This module is pure and transport-free so the policy is unit-testable
19
+ * without spinning up the MCP server. `src/server.ts` owns the singleton and
20
+ * supplies the per-call agent key (the session/agent id from
21
+ * currentAttribution()).
22
+ */
23
+ /**
24
+ * A rolling-window call counter bucketed per agent-context key. Each key gets
25
+ * an independent window + counter, so concurrent subagents do not consume one
26
+ * another's budget while a single greedy actor is still throttled and blocked
27
+ * exactly as before.
28
+ */
29
+ export class FloodGuard {
30
+ #cfg;
31
+ #buckets = new Map();
32
+ /**
33
+ * Hard ceiling on tracked keys — a defensive bound so a pathological host
34
+ * that mints unbounded distinct agent ids cannot grow the map without limit.
35
+ * When exceeded, the oldest-window bucket is evicted (its actor simply gets
36
+ * a fresh window on its next call — fail-open, never a false block).
37
+ */
38
+ #maxKeys;
39
+ constructor(cfg, maxKeys = 4096) {
40
+ this.#cfg = cfg;
41
+ this.#maxKeys = Math.max(1, maxKeys);
42
+ }
43
+ /**
44
+ * Record one ctx_search call for `key` at time `now` (ms) and return the
45
+ * throttle decision. Pure aside from the internal per-key counter state.
46
+ */
47
+ record(key, now = Date.now()) {
48
+ let bucket = this.#buckets.get(key);
49
+ if (!bucket || now - bucket.windowStart > this.#cfg.windowMs) {
50
+ bucket = { count: 0, windowStart: now };
51
+ this.#buckets.set(key, bucket);
52
+ this.#evictIfNeeded();
53
+ }
54
+ bucket.count++;
55
+ return {
56
+ count: bucket.count,
57
+ windowStart: bucket.windowStart,
58
+ blocked: bucket.count > this.#cfg.blockAfter,
59
+ softCapped: bucket.count > this.#cfg.softCapAfter,
60
+ };
61
+ }
62
+ /** Test/diagnostics helper — number of distinct keys currently tracked. */
63
+ size() {
64
+ return this.#buckets.size;
65
+ }
66
+ #evictIfNeeded() {
67
+ if (this.#buckets.size <= this.#maxKeys)
68
+ return;
69
+ let oldestKey;
70
+ let oldestStart = Infinity;
71
+ for (const [k, b] of this.#buckets) {
72
+ if (b.windowStart < oldestStart) {
73
+ oldestStart = b.windowStart;
74
+ oldestKey = k;
75
+ }
76
+ }
77
+ if (oldestKey !== undefined)
78
+ this.#buckets.delete(oldestKey);
79
+ }
80
+ }