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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +39 -7
- package/bin/statusline.mjs +321 -0
- package/build/adapters/antigravity/index.d.ts +6 -0
- package/build/adapters/antigravity/index.js +10 -0
- package/build/adapters/base.d.ts +23 -0
- package/build/adapters/base.js +29 -0
- package/build/adapters/codex/index.d.ts +10 -0
- package/build/adapters/codex/index.js +22 -4
- package/build/adapters/cursor/index.d.ts +7 -0
- package/build/adapters/cursor/index.js +11 -0
- package/build/adapters/detect.d.ts +12 -1
- package/build/adapters/detect.js +69 -7
- package/build/adapters/gemini-cli/index.d.ts +8 -1
- package/build/adapters/gemini-cli/index.js +19 -7
- package/build/adapters/jetbrains-copilot/index.d.ts +7 -0
- package/build/adapters/jetbrains-copilot/index.js +12 -0
- package/build/adapters/kiro/index.d.ts +8 -0
- package/build/adapters/kiro/index.js +12 -0
- package/build/adapters/openclaw/index.d.ts +17 -0
- package/build/adapters/openclaw/index.js +29 -4
- package/build/adapters/opencode/index.d.ts +8 -0
- package/build/adapters/opencode/index.js +18 -6
- package/build/adapters/qwen-code/index.d.ts +1 -0
- package/build/adapters/qwen-code/index.js +3 -0
- package/build/adapters/types.d.ts +33 -0
- package/build/adapters/vscode-copilot/index.d.ts +6 -0
- package/build/adapters/vscode-copilot/index.js +10 -0
- package/build/adapters/zed/index.d.ts +1 -0
- package/build/adapters/zed/index.js +3 -0
- package/build/cli.d.ts +15 -0
- package/build/cli.js +62 -16
- package/build/concurrency/runPool.d.ts +36 -0
- package/build/concurrency/runPool.js +51 -0
- package/build/executor.d.ts +11 -1
- package/build/executor.js +77 -21
- package/build/fetch-cache.d.ts +13 -0
- package/build/fetch-cache.js +15 -0
- package/build/lifecycle.d.ts +6 -2
- package/build/lifecycle.js +29 -2
- package/build/opencode-plugin.d.ts +23 -0
- package/build/opencode-plugin.js +80 -6
- package/build/routing-block.d.ts +8 -0
- package/build/routing-block.js +86 -0
- package/build/runtime.d.ts +1 -0
- package/build/runtime.js +54 -3
- package/build/search/auto-memory.d.ts +23 -10
- package/build/search/auto-memory.js +64 -26
- package/build/search/unified.d.ts +3 -0
- package/build/search/unified.js +2 -2
- package/build/server.d.ts +47 -0
- package/build/server.js +736 -188
- package/build/session/analytics.d.ts +49 -1
- package/build/session/analytics.js +278 -16
- package/build/session/db.d.ts +53 -8
- package/build/session/db.js +200 -19
- package/build/session/extract.js +124 -2
- package/build/tool-naming.d.ts +4 -0
- package/build/tool-naming.js +24 -0
- package/cli.bundle.mjs +208 -158
- package/configs/antigravity/GEMINI.md +11 -0
- package/configs/claude-code/CLAUDE.md +11 -0
- package/configs/codex/AGENTS.md +11 -0
- package/configs/cursor/context-mode.mdc +11 -0
- package/configs/gemini-cli/GEMINI.md +11 -0
- package/configs/jetbrains-copilot/copilot-instructions.md +3 -0
- package/configs/kilo/AGENTS.md +11 -0
- package/configs/kiro/KIRO.md +11 -0
- package/configs/openclaw/AGENTS.md +11 -0
- package/configs/opencode/AGENTS.md +11 -0
- package/configs/pi/AGENTS.md +11 -0
- package/configs/qwen-code/QWEN.md +11 -0
- package/configs/vscode-copilot/copilot-instructions.md +3 -0
- package/configs/zed/AGENTS.md +11 -0
- package/hooks/auto-injection.mjs +36 -10
- package/hooks/cache-heal-utils.mjs +231 -0
- package/hooks/codex/sessionstart.mjs +7 -4
- package/hooks/core/routing.mjs +8 -2
- package/hooks/cursor/sessionstart.mjs +7 -4
- package/hooks/formatters/claude-code.mjs +20 -0
- package/hooks/gemini-cli/sessionstart.mjs +7 -2
- package/hooks/jetbrains-copilot/sessionstart.mjs +7 -2
- package/hooks/normalize-hooks.mjs +184 -0
- package/hooks/session-db.bundle.mjs +41 -14
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +68 -20
- package/hooks/session-loaders.mjs +8 -2
- package/hooks/sessionstart.mjs +8 -2
- package/hooks/vscode-copilot/sessionstart.mjs +7 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/server.bundle.mjs +181 -134
- package/skills/ctx-doctor/SKILL.md +3 -3
- package/skills/ctx-insight/SKILL.md +1 -1
- 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
|
|
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,
|
|
161
|
-
return resolve(homedir(), "AppData", "Local", platform,
|
|
200
|
+
return resolve(localApp, platform, ...subPath);
|
|
201
|
+
return resolve(homedir(), "AppData", "Local", platform, ...subPath);
|
|
162
202
|
}
|
|
163
|
-
return resolve(homedir(), ".cache", platform,
|
|
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
|
-
|
|
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
|
+
}
|
package/build/executor.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/build/lifecycle.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
* owns stdin and transient pipe events cause spurious -32000
|
|
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
|
*/
|
package/build/lifecycle.js
CHANGED
|
@@ -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
|
|
8
|
-
* owns stdin and transient pipe events cause spurious -32000
|
|
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;
|
package/build/opencode-plugin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────
|