context-mode 1.0.101 → 1.0.104

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 +66 -5
  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 +59 -16
  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 +6 -0
  45. package/build/opencode-plugin.js +60 -1
  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 +42 -0
  55. package/build/server.js +693 -164
  56. package/build/session/analytics.d.ts +49 -1
  57. package/build/session/analytics.js +278 -16
  58. package/build/session/db.d.ts +39 -8
  59. package/build/session/db.js +170 -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 +201 -159
  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 +5 -0
  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 +33 -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/insight/src/routes/index.tsx +1 -1
  94. package/openclaw.plugin.json +1 -1
  95. package/package.json +2 -1
  96. package/server.bundle.mjs +164 -125
  97. package/skills/ctx-insight/SKILL.md +1 -1
  98. package/start.mjs +63 -3
package/build/cli.d.ts CHANGED
@@ -15,3 +15,18 @@
15
15
  export declare function toUnixPath(p: string): string;
16
16
  export declare function npmExecFile(args: string[], opts?: Record<string, unknown>): void;
17
17
  export declare function npmExec(command: string, opts?: Record<string, unknown>): void;
18
+ /**
19
+ * Open a URL in the user's default browser without invoking a shell.
20
+ *
21
+ * Uses `execFile` with an arg array so the URL cannot be interpreted as
22
+ * shell metacharacters. Original code used `execSync(`open "${url}"`)`
23
+ * which would shell-interpolate the URL — fragile if the URL ever
24
+ * becomes attacker-controlled (remote, weak port-validation, etc).
25
+ *
26
+ * Best-effort: if the OS opener is missing the function logs a copyable
27
+ * URL hint and returns; it never throws. `runner` is injectable for
28
+ * tests; default is `child_process.execFile` (callback form, fire-and-
29
+ * forget).
30
+ */
31
+ export type ExecFileFn = (file: string, args: readonly string[], opts?: Record<string, unknown>) => unknown;
32
+ export declare function openInBrowser(url: string, platform?: NodeJS.Platform, runner?: ExecFileFn): void;
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): 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,38 @@ 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 get NO extension to avoid Windows file-association
10
+ * for `.sh` (which spawns a visible Git Bash window over the user's IDE).
11
+ */
12
+ const SCRIPT_EXT = {
13
+ javascript: "js",
14
+ typescript: "ts",
15
+ python: "py",
16
+ shell: "sh",
17
+ ruby: "rb",
18
+ go: "go",
19
+ rust: "rs",
20
+ php: "php",
21
+ perl: "pl",
22
+ r: "R",
23
+ elixir: "exs",
24
+ };
25
+ /** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
26
+ export function buildScriptFilename(language, platform) {
27
+ if (platform === "win32" && language === "shell")
28
+ return "script";
29
+ return `script.${SCRIPT_EXT[language]}`;
30
+ }
31
+ /**
32
+ * Pure helper — exported for unit testing. Adds `windowsHide: true` on Windows
33
+ * to prevent the spawned shell from creating a visible console window that
34
+ * intercepts stdout (issue #384).
35
+ */
36
+ export function buildSpawnOptions(platform) {
37
+ return { windowsHide: platform === "win32" };
38
+ }
7
39
  /**
8
40
  * Resolve the real OS temp directory, bypassing any TMPDIR env override.
9
41
  * os.tmpdir() reads TMPDIR from the environment, which some shells/tools
@@ -39,15 +71,34 @@ function killTree(proc) {
39
71
  }
40
72
  export class PolyglotExecutor {
41
73
  #hardCapBytes;
42
- #projectRoot;
74
+ /**
75
+ * Resolves the project root on every access. Stored as a thunk so the
76
+ * executor stays in sync with server-side env-cascade resolvers (e.g.
77
+ * `getProjectDir` in server.ts) instead of capturing a snapshot of
78
+ * `CLAUDE_PROJECT_DIR` at construction time. String inputs are wrapped
79
+ * to preserve constructor backward compatibility.
80
+ */
81
+ #projectRootResolver;
43
82
  #runtimes;
44
83
  /** PIDs of backgrounded processes — killed on cleanup to prevent zombies. */
45
84
  #backgroundedPids = new Set();
46
85
  constructor(opts) {
47
86
  this.#hardCapBytes = opts?.hardCapBytes ?? 100 * 1024 * 1024; // 100MB
48
- this.#projectRoot = opts?.projectRoot ?? process.cwd();
87
+ const pr = opts?.projectRoot;
88
+ if (typeof pr === "function") {
89
+ this.#projectRootResolver = pr;
90
+ }
91
+ else if (typeof pr === "string") {
92
+ this.#projectRootResolver = () => pr;
93
+ }
94
+ else {
95
+ this.#projectRootResolver = () => process.cwd();
96
+ }
49
97
  this.#runtimes = opts?.runtimes ?? detectRuntimes();
50
98
  }
99
+ get #projectRoot() {
100
+ return this.#projectRootResolver();
101
+ }
51
102
  get runtimes() {
52
103
  return { ...this.#runtimes };
53
104
  }
@@ -101,19 +152,6 @@ export class PolyglotExecutor {
101
152
  return this.execute({ language, code: wrappedCode, timeout });
102
153
  }
103
154
  #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
155
  // Go needs a main package wrapper if not present
118
156
  if (language === "go" && !code.includes("package ")) {
119
157
  code = `package main\n\nimport "fmt"\n\nfunc main() {\n${code}\n}\n`;
@@ -127,7 +165,7 @@ export class PolyglotExecutor {
127
165
  const escaped = JSON.stringify(join(this.#projectRoot, "_build/dev/lib"));
128
166
  code = `Path.wildcard(Path.join(${escaped}, "*/ebin"))\n|> Enum.each(&Code.prepend_path/1)\n\n${code}`;
129
167
  }
130
- const fp = join(tmpDir, `script.${extMap[language]}`);
168
+ const fp = join(tmpDir, buildScriptFilename(language, process.platform));
131
169
  if (language === "shell") {
132
170
  writeFileSync(fp, code, { encoding: "utf-8", mode: 0o700 });
133
171
  }
@@ -186,6 +224,11 @@ export class PolyglotExecutor {
186
224
  shell: needsShell,
187
225
  // On Unix, create a new process group so killTree can kill all children
188
226
  detached: !isWin,
227
+ // Hide the spawned-process console window on Windows. Without this,
228
+ // child_process.spawn creates a visible window that intercepts stdout,
229
+ // leaving the MCP response empty and popping a Git Bash terminal over
230
+ // the user's IDE. Issue #384.
231
+ ...buildSpawnOptions(process.platform),
189
232
  });
190
233
  let timedOut = false;
191
234
  let resolved = false;
@@ -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
  }
@@ -66,6 +66,12 @@ declare function createContextModePlugin(ctx: PluginContext): Promise<{
66
66
  "tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
67
67
  "tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
68
68
  "experimental.session.compacting": (input: CompactingHookInput, output: CompactingHookOutput) => Promise<string>;
69
+ "experimental.chat.messages.transform": (_input: unknown, output: {
70
+ messages?: Array<{
71
+ role: string;
72
+ content: string;
73
+ }>;
74
+ } | undefined) => Promise<void>;
69
75
  }>;
70
76
  declare const _default: {
71
77
  server: typeof createContextModePlugin;
@@ -25,9 +25,34 @@ import { SessionDB } from "./session/db.js";
25
25
  import { extractEvents } from "./session/extract.js";
26
26
  import { buildResumeSnapshot } from "./session/snapshot.js";
27
27
  import { OpenCodeAdapter } from "./adapters/opencode/index.js";
28
+ import { PLATFORM_ENV_VARS } from "./adapters/detect.js";
28
29
  // ── Helpers ───────────────────────────────────────────────
30
+ /**
31
+ * Detect whether the plugin is running under KiloCode or OpenCode.
32
+ *
33
+ * Reuses the canonical PLATFORM_ENV_VARS list (src/adapters/detect.ts) instead
34
+ * of hardcoding env var names — single source of truth, future-proof if Kilo
35
+ * or OpenCode add/rename env vars upstream.
36
+ *
37
+ * Order matters: KiloCode is an OpenCode fork and sets `OPENCODE=1` in
38
+ * addition to `KILO_PID`. PLATFORM_ENV_VARS lists `kilo` BEFORE `opencode`
39
+ * so KILO_PID wins the iteration.
40
+ *
41
+ * Pre-fix version was `return process.env.KILO_PID ? "kilo" : "opencode";` —
42
+ * surfaced by github.com/mksglu/context-mode/pull/376 (mikij). Full symmetric
43
+ * fix: also actively check opencode env vars instead of blind fallback.
44
+ */
29
45
  function getPlatform() {
30
- return process.env.KILO_PID ? "kilo" : "opencode";
46
+ for (const [platform, vars] of PLATFORM_ENV_VARS) {
47
+ if (platform !== "kilo" && platform !== "opencode")
48
+ continue;
49
+ if (vars.some((v) => process.env[v])) {
50
+ return platform;
51
+ }
52
+ }
53
+ // Plugin host should always set one of the env vars. Fallback to opencode
54
+ // (the wider ecosystem) when neither is set, for predictable behavior.
55
+ return "opencode";
31
56
  }
32
57
  // ── Plugin Factory ────────────────────────────────────────
33
58
  /**
@@ -52,6 +77,10 @@ async function createContextModePlugin(ctx) {
52
77
  db.ensureSession(sessionId, projectDir);
53
78
  // Clean up old sessions on startup (replaces SessionStart hook)
54
79
  db.cleanupOldSessions(7);
80
+ // Track whether we've already injected the prior-session resume into
81
+ // a chat turn — `experimental.chat.messages.transform` fires on every
82
+ // turn, but we only want to inject once per process (SessionStart-equivalent).
83
+ let sessionStartInjected = false;
55
84
  return {
56
85
  // ── PreToolUse: Routing enforcement ─────────────────
57
86
  "tool.execute.before": async (input, output) => {
@@ -115,6 +144,36 @@ async function createContextModePlugin(ctx) {
115
144
  return "";
116
145
  }
117
146
  },
147
+ // ── SessionStart equivalent (PR #376) ───────────────
148
+ // OpenCode lacks a real SessionStart hook (#14808, #5409) but
149
+ // recently added `experimental.chat.messages.transform`, which
150
+ // fires once per chat turn before messages are sent to the model.
151
+ // We piggyback on the *first* invocation per process to inject the
152
+ // most-recent resume snapshot from a prior session — matching what
153
+ // every other adapter's SessionStart hook does.
154
+ "experimental.chat.messages.transform": async (_input, output) => {
155
+ if (sessionStartInjected)
156
+ return;
157
+ sessionStartInjected = true;
158
+ try {
159
+ // Find the most recent resume snapshot for this project across
160
+ // any prior session. ContextSessionDB has no per-project resume
161
+ // lookup, so we fall back to the current session's resume row.
162
+ const row = db.getResume(sessionId);
163
+ const snapshot = row?.snapshot;
164
+ if (!snapshot || snapshot.length === 0)
165
+ return;
166
+ if (output && Array.isArray(output.messages)) {
167
+ output.messages.unshift({
168
+ role: "system",
169
+ content: snapshot,
170
+ });
171
+ }
172
+ }
173
+ catch {
174
+ // Silent — never break the chat turn
175
+ }
176
+ },
118
177
  };
119
178
  }
120
179
  // ── Exports ──────────────────────────────────────────────
@@ -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
+ }