context-mode 1.0.103 → 1.0.105

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 (98) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +39 -7
  6. package/bin/statusline.mjs +321 -0
  7. package/build/adapters/antigravity/index.d.ts +6 -0
  8. package/build/adapters/antigravity/index.js +10 -0
  9. package/build/adapters/base.d.ts +23 -0
  10. package/build/adapters/base.js +29 -0
  11. package/build/adapters/codex/index.d.ts +10 -0
  12. package/build/adapters/codex/index.js +22 -4
  13. package/build/adapters/cursor/index.d.ts +7 -0
  14. package/build/adapters/cursor/index.js +11 -0
  15. package/build/adapters/detect.d.ts +12 -1
  16. package/build/adapters/detect.js +69 -7
  17. package/build/adapters/gemini-cli/index.d.ts +8 -1
  18. package/build/adapters/gemini-cli/index.js +19 -7
  19. package/build/adapters/jetbrains-copilot/index.d.ts +7 -0
  20. package/build/adapters/jetbrains-copilot/index.js +12 -0
  21. package/build/adapters/kiro/index.d.ts +8 -0
  22. package/build/adapters/kiro/index.js +12 -0
  23. package/build/adapters/openclaw/index.d.ts +17 -0
  24. package/build/adapters/openclaw/index.js +29 -4
  25. package/build/adapters/opencode/index.d.ts +8 -0
  26. package/build/adapters/opencode/index.js +18 -6
  27. package/build/adapters/qwen-code/index.d.ts +1 -0
  28. package/build/adapters/qwen-code/index.js +3 -0
  29. package/build/adapters/types.d.ts +33 -0
  30. package/build/adapters/vscode-copilot/index.d.ts +6 -0
  31. package/build/adapters/vscode-copilot/index.js +10 -0
  32. package/build/adapters/zed/index.d.ts +1 -0
  33. package/build/adapters/zed/index.js +3 -0
  34. package/build/cli.d.ts +15 -0
  35. package/build/cli.js +62 -16
  36. package/build/concurrency/runPool.d.ts +36 -0
  37. package/build/concurrency/runPool.js +51 -0
  38. package/build/executor.d.ts +11 -1
  39. package/build/executor.js +77 -21
  40. package/build/fetch-cache.d.ts +13 -0
  41. package/build/fetch-cache.js +15 -0
  42. package/build/lifecycle.d.ts +6 -2
  43. package/build/lifecycle.js +29 -2
  44. package/build/opencode-plugin.d.ts +23 -0
  45. package/build/opencode-plugin.js +80 -6
  46. package/build/routing-block.d.ts +8 -0
  47. package/build/routing-block.js +86 -0
  48. package/build/runtime.d.ts +1 -0
  49. package/build/runtime.js +54 -3
  50. package/build/search/auto-memory.d.ts +23 -10
  51. package/build/search/auto-memory.js +64 -26
  52. package/build/search/unified.d.ts +3 -0
  53. package/build/search/unified.js +2 -2
  54. package/build/server.d.ts +47 -0
  55. package/build/server.js +736 -188
  56. package/build/session/analytics.d.ts +49 -1
  57. package/build/session/analytics.js +278 -16
  58. package/build/session/db.d.ts +53 -8
  59. package/build/session/db.js +200 -19
  60. package/build/session/extract.js +124 -2
  61. package/build/tool-naming.d.ts +4 -0
  62. package/build/tool-naming.js +24 -0
  63. package/cli.bundle.mjs +208 -158
  64. package/configs/antigravity/GEMINI.md +11 -0
  65. package/configs/claude-code/CLAUDE.md +11 -0
  66. package/configs/codex/AGENTS.md +11 -0
  67. package/configs/cursor/context-mode.mdc +11 -0
  68. package/configs/gemini-cli/GEMINI.md +11 -0
  69. package/configs/jetbrains-copilot/copilot-instructions.md +3 -0
  70. package/configs/kilo/AGENTS.md +11 -0
  71. package/configs/kiro/KIRO.md +11 -0
  72. package/configs/openclaw/AGENTS.md +11 -0
  73. package/configs/opencode/AGENTS.md +11 -0
  74. package/configs/pi/AGENTS.md +11 -0
  75. package/configs/qwen-code/QWEN.md +11 -0
  76. package/configs/vscode-copilot/copilot-instructions.md +3 -0
  77. package/configs/zed/AGENTS.md +11 -0
  78. package/hooks/auto-injection.mjs +36 -10
  79. package/hooks/cache-heal-utils.mjs +231 -0
  80. package/hooks/codex/sessionstart.mjs +7 -4
  81. package/hooks/core/routing.mjs +8 -2
  82. package/hooks/cursor/sessionstart.mjs +7 -4
  83. package/hooks/formatters/claude-code.mjs +20 -0
  84. package/hooks/gemini-cli/sessionstart.mjs +7 -2
  85. package/hooks/jetbrains-copilot/sessionstart.mjs +7 -2
  86. package/hooks/normalize-hooks.mjs +184 -0
  87. package/hooks/session-db.bundle.mjs +41 -14
  88. package/hooks/session-extract.bundle.mjs +2 -2
  89. package/hooks/session-helpers.mjs +68 -20
  90. package/hooks/session-loaders.mjs +8 -2
  91. package/hooks/sessionstart.mjs +8 -2
  92. package/hooks/vscode-copilot/sessionstart.mjs +7 -2
  93. package/openclaw.plugin.json +1 -1
  94. package/package.json +2 -1
  95. package/server.bundle.mjs +181 -134
  96. package/skills/ctx-doctor/SKILL.md +3 -3
  97. package/skills/ctx-insight/SKILL.md +1 -1
  98. package/start.mjs +63 -3
package/build/cli.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import * as p from "@clack/prompts";
15
15
  import color from "picocolors";
16
- import { execFileSync } from "node:child_process";
16
+ import { execFileSync, execFile as nodeExecFile } from "node:child_process";
17
17
  import { readFileSync, writeFileSync, cpSync, accessSync, existsSync, rmSync, closeSync, openSync, chmodSync, constants } from "node:fs";
18
18
  import { request as httpsRequest } from "node:https";
19
19
  import { resolve, dirname, join } from "node:path";
@@ -111,6 +111,11 @@ else if (args[0] === "hook") {
111
111
  else if (args[0] === "insight") {
112
112
  insight(args[1] ? Number(args[1]) : 4747);
113
113
  }
114
+ else if (args[0] === "statusline") {
115
+ // Status line implementation lives in bin/statusline.mjs to keep it
116
+ // dependency-free and fast. Forward stdin and exit with its result.
117
+ statuslineForward();
118
+ }
114
119
  else {
115
120
  // Default: start MCP server
116
121
  import("./server.js");
@@ -142,6 +147,37 @@ export function npmExec(command, opts = {}) {
142
147
  ...(isWin ? { shell: true } : {}),
143
148
  });
144
149
  }
150
+ export function openInBrowser(url, platform = process.platform, runner = nodeExecFile) {
151
+ const opts = { stdio: "ignore" };
152
+ const hint = () => console.error(`\nCould not auto-open browser. Open manually: ${url}`);
153
+ try {
154
+ if (platform === "darwin") {
155
+ runner("open", [url], opts);
156
+ }
157
+ else if (platform === "win32") {
158
+ // `start` is a cmd.exe builtin; first arg after `start` is the
159
+ // window title — pass empty so the URL isn't consumed as a title.
160
+ runner("cmd", ["/c", "start", "", url], opts);
161
+ }
162
+ else {
163
+ // linux/bsd: try xdg-open, fall back to sensible-browser.
164
+ try {
165
+ runner("xdg-open", [url], opts);
166
+ }
167
+ catch {
168
+ try {
169
+ runner("sensible-browser", [url], opts);
170
+ }
171
+ catch {
172
+ hint();
173
+ }
174
+ }
175
+ }
176
+ }
177
+ catch {
178
+ hint();
179
+ }
180
+ }
145
181
  function defaultPluginRoot() {
146
182
  const __filename = fileURLToPath(import.meta.url);
147
183
  const __dirname = dirname(__filename);
@@ -152,15 +188,19 @@ function defaultPluginRoot() {
152
188
  }
153
189
  return __dirname;
154
190
  }
155
- // Opencode/Kilocode install plugins from npm into .cache folder
191
+ // Opencode/Kilocode install plugins from npm into a per-package cache folder.
192
+ // Layout (changed silently in late 2024 — see PR #376 / KiloCode#9503):
193
+ // POSIX : ~/.cache/<platform>/packages/context-mode@latest/node_modules/context-mode
194
+ // Windows: %LOCALAPPDATA%\<platform>\packages\context-mode@latest\node_modules\context-mode
156
195
  function cachePluginRoot(platform) {
196
+ const subPath = ["packages", "context-mode@latest", "node_modules", "context-mode"];
157
197
  if (process.platform === "win32") {
158
198
  const localApp = process.env.LOCALAPPDATA;
159
199
  if (localApp)
160
- return resolve(localApp, platform, "node_modules", "context-mode");
161
- return resolve(homedir(), "AppData", "Local", platform, "node_modules", "context-mode");
200
+ return resolve(localApp, platform, ...subPath);
201
+ return resolve(homedir(), "AppData", "Local", platform, ...subPath);
162
202
  }
163
- return resolve(homedir(), ".cache", platform, "node_modules", "context-mode");
203
+ return resolve(homedir(), ".cache", platform, ...subPath);
164
204
  }
165
205
  function getPluginRoot() {
166
206
  const platform = detectPlatform().platform;
@@ -477,17 +517,8 @@ async function insight(port) {
477
517
  child.kill();
478
518
  process.exit(1);
479
519
  }
480
- // Open browser
481
- const platform = process.platform;
482
- try {
483
- if (platform === "darwin")
484
- execSync(`open "${url}"`, { stdio: "pipe" });
485
- else if (platform === "win32")
486
- execSync(`start "" "${url}"`, { stdio: "pipe" });
487
- else
488
- execSync(`xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null`, { stdio: "pipe" });
489
- }
490
- catch { /* best effort */ }
520
+ // Open browser — execFile with arg array, no shell interpolation.
521
+ openInBrowser(url);
491
522
  // Keep alive until Ctrl+C
492
523
  process.on("SIGINT", () => { child.kill(); process.exit(0); });
493
524
  process.on("SIGTERM", () => { child.kill(); process.exit(0); });
@@ -737,3 +768,18 @@ async function upgrade() {
737
768
  color.dim(` — restart your ${adapter.name} session to pick up the new version`));
738
769
  }
739
770
  }
771
+ /* -------------------------------------------------------
772
+ * statusline — forward to bin/statusline.mjs
773
+ * ------------------------------------------------------- */
774
+ function statuslineForward() {
775
+ const scriptPath = resolve(getPluginRoot(), "bin", "statusline.mjs");
776
+ if (!existsSync(scriptPath)) {
777
+ process.stderr.write(`statusline script missing: ${scriptPath}\n`);
778
+ process.exit(1);
779
+ }
780
+ // Re-exec via dynamic import so stdin/stdout are inherited cleanly.
781
+ import(pathToFileURL(scriptPath).href).catch((err) => {
782
+ process.stderr.write(`statusline failed: ${err?.message ?? err}\n`);
783
+ process.exit(1);
784
+ });
785
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Generic in-flight-capped worker pool.
3
+ *
4
+ * Used by:
5
+ * - runBatchCommands (ctx_batch_execute parallel branch)
6
+ * - runBatchFetch (ctx_fetch_and_index batch path)
7
+ *
8
+ * Returns Promise.allSettled-style results so one job's throw cannot
9
+ * strand siblings. Caller maps fulfilled/rejected per index. Output
10
+ * order is preserved by input index (not completion order).
11
+ *
12
+ * Designed to be the SINGLE concurrency primitive for the project —
13
+ * all "run N independent operations with at most M in flight" needs
14
+ * route here. Avoids the worker-pool copy-paste flagged in the
15
+ * concurrency PRD architectural review (finding G).
16
+ */
17
+ export interface PoolJob<T> {
18
+ run(): Promise<T>;
19
+ }
20
+ export interface RunPoolOptions {
21
+ /** Hard concurrency cap (1-N). Auto-clamped to job count. */
22
+ concurrency: number;
23
+ /** Optional: also clamp by `os.cpus().length` (memory-pressure safety). Default false. */
24
+ capByCpuCount?: boolean;
25
+ /** Optional: per-settled callback (e.g. for progress reporting / metrics). */
26
+ onSettled?: (idx: number, result: PromiseSettledResult<unknown>) => void;
27
+ }
28
+ export interface RunPoolResult<T> {
29
+ /** Per-index settled result, ordered by input index. */
30
+ settled: PromiseSettledResult<T>[];
31
+ /** Concurrency actually used after all caps applied. */
32
+ effectiveConcurrency: number;
33
+ /** True when effectiveConcurrency < requested concurrency. */
34
+ capped: boolean;
35
+ }
36
+ export declare function runPool<T>(jobs: PoolJob<T>[], opts: RunPoolOptions): Promise<RunPoolResult<T>>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Generic in-flight-capped worker pool.
3
+ *
4
+ * Used by:
5
+ * - runBatchCommands (ctx_batch_execute parallel branch)
6
+ * - runBatchFetch (ctx_fetch_and_index batch path)
7
+ *
8
+ * Returns Promise.allSettled-style results so one job's throw cannot
9
+ * strand siblings. Caller maps fulfilled/rejected per index. Output
10
+ * order is preserved by input index (not completion order).
11
+ *
12
+ * Designed to be the SINGLE concurrency primitive for the project —
13
+ * all "run N independent operations with at most M in flight" needs
14
+ * route here. Avoids the worker-pool copy-paste flagged in the
15
+ * concurrency PRD architectural review (finding G).
16
+ */
17
+ import { cpus } from "node:os";
18
+ export async function runPool(jobs, opts) {
19
+ const { concurrency, capByCpuCount = false, onSettled } = opts;
20
+ if (jobs.length === 0) {
21
+ return { settled: [], effectiveConcurrency: 0, capped: false };
22
+ }
23
+ const requested = Math.max(1, concurrency);
24
+ const cpuCap = capByCpuCount ? Math.max(1, cpus().length) : requested;
25
+ const effectiveConcurrency = Math.min(requested, cpuCap, jobs.length);
26
+ const capped = effectiveConcurrency < requested;
27
+ const settled = new Array(jobs.length);
28
+ let nextIdx = 0;
29
+ async function worker() {
30
+ while (true) {
31
+ const idx = nextIdx++;
32
+ if (idx >= jobs.length)
33
+ return;
34
+ try {
35
+ const value = await jobs[idx].run();
36
+ settled[idx] = { status: "fulfilled", value };
37
+ }
38
+ catch (err) {
39
+ settled[idx] = { status: "rejected", reason: err };
40
+ }
41
+ onSettled?.(idx, settled[idx]);
42
+ }
43
+ }
44
+ const workers = [];
45
+ for (let w = 0; w < effectiveConcurrency; w++)
46
+ workers.push(worker());
47
+ // allSettled defends against any promise rejection escaping a worker
48
+ // (the worker already swallows its own errors, but this is belt-and-braces).
49
+ await Promise.allSettled(workers);
50
+ return { settled, effectiveConcurrency, capped };
51
+ }
@@ -1,6 +1,16 @@
1
1
  import { type RuntimeMap, type Language } from "./runtime.js";
2
2
  export type { ExecResult } from "./types.js";
3
3
  import type { ExecResult } from "./types.js";
4
+ /** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
5
+ export declare function buildScriptFilename(language: Language, platform: NodeJS.Platform, shellPath?: string | null): string;
6
+ /**
7
+ * Pure helper — exported for unit testing. Adds `windowsHide: true` on Windows
8
+ * to prevent the spawned shell from creating a visible console window that
9
+ * intercepts stdout (issue #384).
10
+ */
11
+ export declare function buildSpawnOptions(platform: NodeJS.Platform): {
12
+ windowsHide: boolean;
13
+ };
4
14
  interface ExecuteOptions {
5
15
  language: Language;
6
16
  code: string;
@@ -15,7 +25,7 @@ export declare class PolyglotExecutor {
15
25
  #private;
16
26
  constructor(opts?: {
17
27
  hardCapBytes?: number;
18
- projectRoot?: string;
28
+ projectRoot?: string | (() => string);
19
29
  runtimes?: RuntimeMap;
20
30
  });
21
31
  get runtimes(): RuntimeMap;
package/build/executor.js CHANGED
@@ -4,6 +4,44 @@ import { join, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { detectRuntimes, buildCommand, } from "./runtime.js";
6
6
  const isWin = process.platform === "win32";
7
+ /**
8
+ * Pure helper: extension map for temp script files per language.
9
+ * On Windows, shell scripts usually get NO extension to avoid Windows
10
+ * file-association for `.sh` (which spawns a visible Git Bash window over the
11
+ * user's IDE). Windows PowerShell/pwsh is the exception because `-File`
12
+ * requires `.ps1` there.
13
+ */
14
+ const SCRIPT_EXT = {
15
+ javascript: "js",
16
+ typescript: "ts",
17
+ python: "py",
18
+ shell: "sh",
19
+ ruby: "rb",
20
+ go: "go",
21
+ rust: "rs",
22
+ php: "php",
23
+ perl: "pl",
24
+ r: "R",
25
+ elixir: "exs",
26
+ };
27
+ /** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
28
+ export function buildScriptFilename(language, platform, shellPath) {
29
+ if (platform === "win32" && language === "shell") {
30
+ const shellName = shellPath?.toLowerCase() ?? "";
31
+ return shellName.includes("powershell") || shellName.includes("pwsh")
32
+ ? "script.ps1"
33
+ : "script";
34
+ }
35
+ return `script.${SCRIPT_EXT[language]}`;
36
+ }
37
+ /**
38
+ * Pure helper — exported for unit testing. Adds `windowsHide: true` on Windows
39
+ * to prevent the spawned shell from creating a visible console window that
40
+ * intercepts stdout (issue #384).
41
+ */
42
+ export function buildSpawnOptions(platform) {
43
+ return { windowsHide: platform === "win32" };
44
+ }
7
45
  /**
8
46
  * Resolve the real OS temp directory, bypassing any TMPDIR env override.
9
47
  * os.tmpdir() reads TMPDIR from the environment, which some shells/tools
@@ -39,15 +77,34 @@ function killTree(proc) {
39
77
  }
40
78
  export class PolyglotExecutor {
41
79
  #hardCapBytes;
42
- #projectRoot;
80
+ /**
81
+ * Resolves the project root on every access. Stored as a thunk so the
82
+ * executor stays in sync with server-side env-cascade resolvers (e.g.
83
+ * `getProjectDir` in server.ts) instead of capturing a snapshot of
84
+ * `CLAUDE_PROJECT_DIR` at construction time. String inputs are wrapped
85
+ * to preserve constructor backward compatibility.
86
+ */
87
+ #projectRootResolver;
43
88
  #runtimes;
44
89
  /** PIDs of backgrounded processes — killed on cleanup to prevent zombies. */
45
90
  #backgroundedPids = new Set();
46
91
  constructor(opts) {
47
92
  this.#hardCapBytes = opts?.hardCapBytes ?? 100 * 1024 * 1024; // 100MB
48
- this.#projectRoot = opts?.projectRoot ?? process.cwd();
93
+ const pr = opts?.projectRoot;
94
+ if (typeof pr === "function") {
95
+ this.#projectRootResolver = pr;
96
+ }
97
+ else if (typeof pr === "string") {
98
+ this.#projectRootResolver = () => pr;
99
+ }
100
+ else {
101
+ this.#projectRootResolver = () => process.cwd();
102
+ }
49
103
  this.#runtimes = opts?.runtimes ?? detectRuntimes();
50
104
  }
105
+ get #projectRoot() {
106
+ return this.#projectRootResolver();
107
+ }
51
108
  get runtimes() {
52
109
  return { ...this.#runtimes };
53
110
  }
@@ -63,7 +120,7 @@ export class PolyglotExecutor {
63
120
  this.#backgroundedPids.clear();
64
121
  }
65
122
  async execute(opts) {
66
- const { language, code, timeout = 30_000, background = false } = opts;
123
+ const { language, code, timeout, background = false } = opts;
67
124
  const tmpDir = mkdtempSync(join(OS_TMPDIR, ".ctx-mode-"));
68
125
  try {
69
126
  const filePath = this.#writeScript(tmpDir, code, language);
@@ -95,25 +152,12 @@ export class PolyglotExecutor {
95
152
  }
96
153
  }
97
154
  async executeFile(opts) {
98
- const { path: filePath, language, code, timeout = 30_000 } = opts;
155
+ const { path: filePath, language, code, timeout } = opts;
99
156
  const absolutePath = resolve(this.#projectRoot, filePath);
100
157
  const wrappedCode = this.#wrapWithFileContent(absolutePath, language, code);
101
158
  return this.execute({ language, code: wrappedCode, timeout });
102
159
  }
103
160
  #writeScript(tmpDir, code, language) {
104
- const extMap = {
105
- javascript: "js",
106
- typescript: "ts",
107
- python: "py",
108
- shell: "sh",
109
- ruby: "rb",
110
- go: "go",
111
- rust: "rs",
112
- php: "php",
113
- perl: "pl",
114
- r: "R",
115
- elixir: "exs",
116
- };
117
161
  // Go needs a main package wrapper if not present
118
162
  if (language === "go" && !code.includes("package ")) {
119
163
  code = `package main\n\nimport "fmt"\n\nfunc main() {\n${code}\n}\n`;
@@ -127,7 +171,7 @@ export class PolyglotExecutor {
127
171
  const escaped = JSON.stringify(join(this.#projectRoot, "_build/dev/lib"));
128
172
  code = `Path.wildcard(Path.join(${escaped}, "*/ebin"))\n|> Enum.each(&Code.prepend_path/1)\n\n${code}`;
129
173
  }
130
- const fp = join(tmpDir, `script.${extMap[language]}`);
174
+ const fp = join(tmpDir, buildScriptFilename(language, process.platform, language === "shell" ? this.#runtimes.shell : null));
131
175
  if (language === "shell") {
132
176
  writeFileSync(fp, code, { encoding: "utf-8", mode: 0o700 });
133
177
  }
@@ -139,11 +183,13 @@ export class PolyglotExecutor {
139
183
  async #compileAndRun(srcPath, cwd, timeout) {
140
184
  const binSuffix = isWin ? ".exe" : "";
141
185
  const binPath = srcPath.replace(/\.rs$/, "") + binSuffix;
142
- // Compile
186
+ // Compile — cap rustc invocation at 60s when caller didn't bound the
187
+ // overall timeout (a hung compile shouldn't run forever even if the
188
+ // caller is fine with a long-running binary afterwards).
143
189
  try {
144
190
  execFileSync("rustc", [srcPath, "-o", binPath], {
145
191
  cwd,
146
- timeout: Math.min(timeout, 60_000),
192
+ timeout: timeout === undefined ? 60_000 : Math.min(timeout, 60_000),
147
193
  encoding: "utf-8",
148
194
  stdio: ["pipe", "pipe", "pipe"],
149
195
  });
@@ -186,10 +232,20 @@ export class PolyglotExecutor {
186
232
  shell: needsShell,
187
233
  // On Unix, create a new process group so killTree can kill all children
188
234
  detached: !isWin,
235
+ // Hide the spawned-process console window on Windows. Without this,
236
+ // child_process.spawn creates a visible window that intercepts stdout,
237
+ // leaving the MCP response empty and popping a Git Bash terminal over
238
+ // the user's IDE. Issue #384.
239
+ ...buildSpawnOptions(process.platform),
189
240
  });
190
241
  let timedOut = false;
191
242
  let resolved = false;
192
- const timer = setTimeout(() => {
243
+ // Issue #406 if the caller didn't pass a timeout we don't fire one.
244
+ // Timeout policy belongs to the MCP host/client (Claude Code, VSCode,
245
+ // JetBrains all enforce their own RPC timeouts); imposing a second
246
+ // policy here turned 30-minute Gradle/Maven/SBT builds into spurious
247
+ // false negatives whenever the caller forgot the explicit value.
248
+ const timer = timeout === undefined ? undefined : setTimeout(() => {
193
249
  timedOut = true;
194
250
  if (background) {
195
251
  // Background mode: detach process, return partial output, keep running
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Cache-key / storage-label composition for ctx_fetch_and_index.
3
+ *
4
+ * Two distinct URLs that share a user-supplied `source` label MUST NOT collide
5
+ * in the cache (or in FTS5 storage, since indexing dedups by label). Compose
6
+ * `${source}::${url}` whenever a `source` is explicitly provided so cache
7
+ * lookup, dedup, and re-indexing are all per-(source,url). When no `source`
8
+ * is provided the URL itself is the unique key — no composition needed.
9
+ *
10
+ * `ctx_search(source: "Docs")` continues to work because LIKE-mode source
11
+ * filtering matches on the substring "Docs" inside "Docs::https://…".
12
+ */
13
+ export declare function composeFetchCacheKey(source: string | undefined, url: string): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Cache-key / storage-label composition for ctx_fetch_and_index.
3
+ *
4
+ * Two distinct URLs that share a user-supplied `source` label MUST NOT collide
5
+ * in the cache (or in FTS5 storage, since indexing dedups by label). Compose
6
+ * `${source}::${url}` whenever a `source` is explicitly provided so cache
7
+ * lookup, dedup, and re-indexing are all per-(source,url). When no `source`
8
+ * is provided the URL itself is the unique key — no composition needed.
9
+ *
10
+ * `ctx_search(source: "Docs")` continues to work because LIKE-mode source
11
+ * filtering matches on the substring "Docs" inside "Docs::https://…".
12
+ */
13
+ export function composeFetchCacheKey(source, url) {
14
+ return source === undefined ? url : `${source}::${url}`;
15
+ }
@@ -4,8 +4,12 @@
4
4
  * Detects parent process death (ppid polling) and OS signals to prevent
5
5
  * orphaned MCP server processes consuming 100% CPU (issue #103).
6
6
  *
7
- * Stdin close is NOT used as a shutdown signal — the MCP stdio transport
8
- * owns stdin and transient pipe events cause spurious -32000 errors (#236).
7
+ * Stdin close is NOT used as a *standalone* shutdown signal — the MCP stdio
8
+ * transport owns stdin and transient pipe events cause spurious -32000
9
+ * errors (#236). We do, however, treat stdin EOF as a hint to re-run the
10
+ * parent-liveness probe immediately (instead of waiting up to 30 s for the
11
+ * next poll tick), which closes the multi-day CPU-spin window seen in
12
+ * #311/#388 without reintroducing the false-positive shutdowns of #236.
9
13
  *
10
14
  * Cross-platform: macOS, Linux, Windows.
11
15
  */
@@ -4,8 +4,12 @@
4
4
  * Detects parent process death (ppid polling) and OS signals to prevent
5
5
  * orphaned MCP server processes consuming 100% CPU (issue #103).
6
6
  *
7
- * Stdin close is NOT used as a shutdown signal — the MCP stdio transport
8
- * owns stdin and transient pipe events cause spurious -32000 errors (#236).
7
+ * Stdin close is NOT used as a *standalone* shutdown signal — the MCP stdio
8
+ * transport owns stdin and transient pipe events cause spurious -32000
9
+ * errors (#236). We do, however, treat stdin EOF as a hint to re-run the
10
+ * parent-liveness probe immediately (instead of waiting up to 30 s for the
11
+ * next poll tick), which closes the multi-day CPU-spin window seen in
12
+ * #311/#388 without reintroducing the false-positive shutdowns of #236.
9
13
  *
10
14
  * Cross-platform: macOS, Linux, Windows.
11
15
  */
@@ -93,10 +97,33 @@ export function startLifecycleGuard(opts) {
93
97
  signals.push("SIGHUP");
94
98
  for (const sig of signals)
95
99
  process.on(sig, shutdown);
100
+ // P0: Stdin-EOF assist (#311/#388). The vendored MCP SDK's
101
+ // StdioServerTransport only registers 'data' / 'error' listeners — not
102
+ // 'end' — so when the parent (e.g. Claude Code) dies abruptly without
103
+ // sending SIGTERM, the server keeps reading from a half-closed pipe and
104
+ // CPU-spins until the 30 s ppid poll catches up. Observed in #388 with
105
+ // single processes accumulating ~80 h of CPU time before SIGKILL.
106
+ //
107
+ // We deliberately DO NOT call shutdown() unconditionally on 'end' — that
108
+ // is exactly the false-positive behavior #236 tore out. Instead we run
109
+ // the same isParentAlive() check the periodic timer uses, just earlier.
110
+ // If the parent is alive, this is a no-op and the existing #236
111
+ // regression test still passes; if the parent is gone, we collapse the
112
+ // 30 s detection window to ~0.
113
+ //
114
+ // Skipped on TTY (OpenCode ts-plugin) where stdin is not the MCP channel.
115
+ const onStdinEnd = () => {
116
+ if (!check())
117
+ shutdown();
118
+ };
119
+ if (!process.stdin.isTTY) {
120
+ process.stdin.on("end", onStdinEnd);
121
+ }
96
122
  return () => {
97
123
  stopped = true;
98
124
  clearInterval(timer);
99
125
  for (const sig of signals)
100
126
  process.removeListener(sig, shutdown);
127
+ process.stdin.removeListener("end", onStdinEnd);
101
128
  };
102
129
  }
@@ -55,6 +55,28 @@ interface CompactingHookOutput {
55
55
  context: string[];
56
56
  prompt?: string;
57
57
  }
58
+ /**
59
+ * OpenCode experimental.chat.system.transform — first parameter.
60
+ * Verified against sst/opencode/dev/packages/plugin/src/index.ts:
61
+ * input: { sessionID?: string; model: Model }
62
+ * `sessionID` is optional in the SDK type but is in practice always set
63
+ * (the transform runs *for* a session). We treat it as required and
64
+ * skip injection when absent rather than fall back to a fabricated ID.
65
+ *
66
+ * NOTE: We deliberately do NOT use `experimental.chat.messages.transform`.
67
+ * Its SDK input shape is `{}` (no sessionID) and its output is
68
+ * `{ messages: { info: Message; parts: Part[] }[] }` — the prior code
69
+ * (`output.messages.unshift({ role, content })`) wrote a value of the
70
+ * wrong shape and was silently dropped (Mickey / PR #376 root cause).
71
+ */
72
+ interface SystemTransformHookInput {
73
+ sessionID?: string;
74
+ model: unknown;
75
+ }
76
+ /** OpenCode experimental.chat.system.transform — second parameter */
77
+ interface SystemTransformHookOutput {
78
+ system: string[];
79
+ }
58
80
  /**
59
81
  * Plugin factory. Called once when KiloCode/OpenCode loads the plugin.
60
82
  * Returns an object mapping hook event names to async handler functions.
@@ -66,6 +88,7 @@ declare function createContextModePlugin(ctx: PluginContext): Promise<{
66
88
  "tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
67
89
  "tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
68
90
  "experimental.session.compacting": (input: CompactingHookInput, output: CompactingHookOutput) => Promise<string>;
91
+ "experimental.chat.system.transform": (input: SystemTransformHookInput, output: SystemTransformHookOutput) => Promise<void>;
69
92
  }>;
70
93
  declare const _default: {
71
94
  server: typeof createContextModePlugin;
@@ -18,16 +18,40 @@
18
18
  * - No routing file auto-write (avoid dirtying project trees)
19
19
  * - Session cleanup happens at plugin init (no SessionStart)
20
20
  */
21
- import { randomUUID } from "node:crypto";
22
21
  import { dirname, resolve } from "node:path";
23
22
  import { fileURLToPath, pathToFileURL } from "node:url";
24
23
  import { SessionDB } from "./session/db.js";
25
24
  import { extractEvents } from "./session/extract.js";
26
25
  import { buildResumeSnapshot } from "./session/snapshot.js";
27
26
  import { OpenCodeAdapter } from "./adapters/opencode/index.js";
27
+ import { PLATFORM_ENV_VARS } from "./adapters/detect.js";
28
28
  // ── Helpers ───────────────────────────────────────────────
29
+ /**
30
+ * Detect whether the plugin is running under KiloCode or OpenCode.
31
+ *
32
+ * Reuses the canonical PLATFORM_ENV_VARS list (src/adapters/detect.ts) instead
33
+ * of hardcoding env var names — single source of truth, future-proof if Kilo
34
+ * or OpenCode add/rename env vars upstream.
35
+ *
36
+ * Order matters: KiloCode is an OpenCode fork and sets `OPENCODE=1` in
37
+ * addition to `KILO_PID`. PLATFORM_ENV_VARS lists `kilo` BEFORE `opencode`
38
+ * so KILO_PID wins the iteration.
39
+ *
40
+ * Pre-fix version was `return process.env.KILO_PID ? "kilo" : "opencode";` —
41
+ * surfaced by github.com/mksglu/context-mode/pull/376 (mikij). Full symmetric
42
+ * fix: also actively check opencode env vars instead of blind fallback.
43
+ */
29
44
  function getPlatform() {
30
- return process.env.KILO_PID ? "kilo" : "opencode";
45
+ for (const [platform, vars] of PLATFORM_ENV_VARS) {
46
+ if (platform !== "kilo" && platform !== "opencode")
47
+ continue;
48
+ if (vars.some((v) => process.env[v])) {
49
+ return platform;
50
+ }
51
+ }
52
+ // Plugin host should always set one of the env vars. Fallback to opencode
53
+ // (the wider ecosystem) when neither is set, for predictable behavior.
54
+ return "opencode";
31
55
  }
32
56
  // ── Plugin Factory ────────────────────────────────────────
33
57
  /**
@@ -45,13 +69,18 @@ async function createContextModePlugin(ctx) {
45
69
  const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
46
70
  const routing = await import(pathToFileURL(routingPath).href);
47
71
  await routing.initSecurity(buildDir);
48
- // Initialize session
72
+ // Initialize per-process state. We do NOT fabricate a sessionId here —
73
+ // OpenCode/Kilo provide the real `input.sessionID` on every hook, and a
74
+ // process-global UUID would (a) never match prior-session resume rows and
75
+ // (b) collide across multi-session reuse (Mickey / PR #376 root cause).
49
76
  const projectDir = ctx.directory;
50
77
  const db = new SessionDB({ dbPath: adapter.getSessionDBPath(projectDir) });
51
- const sessionId = randomUUID();
52
- db.ensureSession(sessionId, projectDir);
53
- // Clean up old sessions on startup (replaces SessionStart hook)
78
+ // Clean up old sessions on startup (no SessionStart hook to do this).
54
79
  db.cleanupOldSessions(7);
80
+ // Track per-session resume injection: persistent plugin process can host
81
+ // many sessions, so the gate must be keyed by sessionID — NOT a single
82
+ // boolean closure flag (Mickey #2 root cause).
83
+ const resumeInjected = new Set();
55
84
  return {
56
85
  // ── PreToolUse: Routing enforcement ─────────────────
57
86
  "tool.execute.before": async (input, output) => {
@@ -78,7 +107,11 @@ async function createContextModePlugin(ctx) {
78
107
  },
79
108
  // ── PostToolUse: Session event capture ──────────────
80
109
  "tool.execute.after": async (input, output) => {
110
+ const sessionId = input.sessionID;
111
+ if (!sessionId)
112
+ return;
81
113
  try {
114
+ db.ensureSession(sessionId, projectDir);
82
115
  const hookInput = {
83
116
  tool_name: input.tool ?? "",
84
117
  tool_input: input.args ?? {},
@@ -97,7 +130,11 @@ async function createContextModePlugin(ctx) {
97
130
  },
98
131
  // ── PreCompact: Snapshot generation ─────────────────
99
132
  "experimental.session.compacting": async (input, output) => {
133
+ const sessionId = input.sessionID;
134
+ if (!sessionId)
135
+ return "";
100
136
  try {
137
+ db.ensureSession(sessionId, projectDir);
101
138
  const events = db.getEvents(sessionId);
102
139
  if (events.length === 0)
103
140
  return "";
@@ -115,6 +152,43 @@ async function createContextModePlugin(ctx) {
115
152
  return "";
116
153
  }
117
154
  },
155
+ // ── SessionStart equivalent (PR #376) ───────────────
156
+ // OpenCode lacks a real SessionStart hook (#14808, #5409). The closest
157
+ // surrogate is `experimental.chat.system.transform` — verified shape:
158
+ // input: { sessionID?: string; model: Model }
159
+ // output: { system: string[] }
160
+ // We claim the most-recent unconsumed resume snapshot atomically (race-
161
+ // safe across concurrent processes) and prepend it to the system prompt.
162
+ // First-injection-per-session is enforced by `resumeInjected` Set.
163
+ "experimental.chat.system.transform": async (input, output) => {
164
+ const sessionId = input?.sessionID;
165
+ if (!sessionId)
166
+ return;
167
+ if (resumeInjected.has(sessionId))
168
+ return;
169
+ resumeInjected.add(sessionId);
170
+ try {
171
+ const row = db.claimLatestUnconsumedResume();
172
+ if (!row || !row.snapshot)
173
+ return;
174
+ if (Array.isArray(output?.system)) {
175
+ // Insert at index 1 (after the header) — NOT unshift.
176
+ // OpenCode's llm.ts:117-128 saves `header = system[0]` BEFORE this
177
+ // hook runs and then folds the rest into a 2-part structure
178
+ // `[header, body]` only if `system[0] === header` after the hook.
179
+ // Prepending via unshift replaces system[0] with the snapshot,
180
+ // making the equality check fail → cache-fold is skipped → every
181
+ // system block is sent as a separate `role: "system"` message →
182
+ // provider prompt cache is invalidated on every resume injection.
183
+ // Inserting at index 1 keeps the header invariant and lets the
184
+ // snapshot ride along inside the cached body block.
185
+ output.system.splice(1, 0, row.snapshot);
186
+ }
187
+ }
188
+ catch {
189
+ // Silent — never break the chat turn
190
+ }
191
+ },
118
192
  };
119
193
  }
120
194
  // ── Exports ──────────────────────────────────────────────