context-mode 1.0.104 → 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 +2 -31
- package/build/executor.d.ts +1 -1
- package/build/executor.js +24 -11
- package/build/opencode-plugin.d.ts +23 -6
- package/build/opencode-plugin.js +44 -29
- package/build/server.d.ts +7 -2
- package/build/server.js +52 -33
- package/build/session/db.d.ts +14 -0
- package/build/session/db.js +30 -0
- package/cli.bundle.mjs +132 -124
- package/hooks/core/routing.mjs +3 -2
- package/hooks/session-db.bundle.mjs +11 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +131 -123
- package/skills/ctx-doctor/SKILL.md +3 -3
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Claude Code plugins by Mert Koseoğlu",
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.105"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "context-mode",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
16
|
-
"version": "1.0.
|
|
16
|
+
"version": "1.0.105",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Mert Koseoğlu"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.105",
|
|
4
4
|
"description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "Context Mode",
|
|
4
4
|
"kind": "tool",
|
|
5
5
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
6
|
-
"version": "1.0.
|
|
6
|
+
"version": "1.0.105",
|
|
7
7
|
"sandbox": {
|
|
8
8
|
"mode": "permissive",
|
|
9
9
|
"filesystem_access": "full",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.105",
|
|
4
4
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
package/README.md
CHANGED
|
@@ -848,46 +848,17 @@ npm install -g context-mode
|
|
|
848
848
|
|
|
849
849
|
| Tool | What it does | Context saved |
|
|
850
850
|
|---|---|---|
|
|
851
|
-
| `ctx_batch_execute` | Run multiple commands + search multiple queries in ONE call. | 986 KB → 62 KB |
|
|
851
|
+
| `ctx_batch_execute` | Run multiple commands + search multiple queries in ONE call. Opt-in `concurrency: 1-8` for I/O-bound batches. | 986 KB → 62 KB |
|
|
852
852
|
| `ctx_execute` | Run code in 11 languages. Only stdout enters context. | 56 KB → 299 B |
|
|
853
853
|
| `ctx_execute_file` | Process files in sandbox. Raw content never leaves. | 45 KB → 155 B |
|
|
854
854
|
| `ctx_index` | Chunk markdown into FTS5 with BM25 ranking. | 60 KB → 40 B |
|
|
855
855
|
| `ctx_search` | Query indexed content with multiple queries in one call. | On-demand retrieval |
|
|
856
|
-
| `ctx_fetch_and_index` | Fetch URL, chunk and index. 24h TTL cache — repeat calls skip network. `force: true` to bypass. | 60 KB → 40 B |
|
|
856
|
+
| `ctx_fetch_and_index` | Fetch URL, chunk and index. 24h TTL cache — repeat calls skip network. `force: true` to bypass. Pass `requests: [{url, source}, ...]` + `concurrency: 1-8` for parallel multi-URL. | 60 KB → 40 B |
|
|
857
857
|
| `ctx_stats` | Show context savings, call counts, and session statistics. | — |
|
|
858
858
|
| `ctx_doctor` | Diagnose installation: runtimes, hooks, FTS5, versions. | — |
|
|
859
859
|
| `ctx_upgrade` | Upgrade to latest version from GitHub, rebuild, reconfigure hooks. | — |
|
|
860
860
|
| `ctx_purge` | Permanently deletes all indexed content from the knowledge base. | — |
|
|
861
861
|
|
|
862
|
-
### Parallel I/O — opt-in concurrency
|
|
863
|
-
|
|
864
|
-
Two batch tools accept `concurrency: N` (1–8, default 1) to fan out independent I/O operations:
|
|
865
|
-
|
|
866
|
-
```js
|
|
867
|
-
// Multi-URL research — fetches in parallel, FTS5 writes serial
|
|
868
|
-
ctx_fetch_and_index({
|
|
869
|
-
requests: [
|
|
870
|
-
{ url: "https://react.dev/...", source: "react" },
|
|
871
|
-
{ url: "https://vue.org/...", source: "vue" },
|
|
872
|
-
// ...
|
|
873
|
-
],
|
|
874
|
-
concurrency: 5,
|
|
875
|
-
})
|
|
876
|
-
|
|
877
|
-
// Multi-API batch — gh, curl, dig, docker inspect
|
|
878
|
-
ctx_batch_execute({
|
|
879
|
-
commands: [
|
|
880
|
-
{ label: "issue-1", command: "gh issue view 1" },
|
|
881
|
-
{ label: "issue-2", command: "gh issue view 2" },
|
|
882
|
-
// ...
|
|
883
|
-
],
|
|
884
|
-
queries: ["..."],
|
|
885
|
-
concurrency: 4,
|
|
886
|
-
})
|
|
887
|
-
```
|
|
888
|
-
|
|
889
|
-
**Use 4–8** for I/O-bound work (network, gh, curl). **Keep at 1** for CPU-bound (npm test, build, lint) or commands sharing state (ports, lock files, same-repo writes). Effective concurrency caps at `os.cpus().length` automatically. Indexing always serial regardless — only the fetches race.
|
|
890
|
-
|
|
891
862
|
## How the Sandbox Works
|
|
892
863
|
|
|
893
864
|
Each `ctx_execute` call spawns an isolated subprocess with its own process boundary. Scripts can't access each other's memory or state. The subprocess runs your code, captures stdout, and only that stdout enters the conversation context. The raw data — log files, API responses, snapshots — never leaves the sandbox.
|
package/build/executor.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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
4
|
/** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
|
|
5
|
-
export declare function buildScriptFilename(language: Language, platform: NodeJS.Platform): string;
|
|
5
|
+
export declare function buildScriptFilename(language: Language, platform: NodeJS.Platform, shellPath?: string | null): string;
|
|
6
6
|
/**
|
|
7
7
|
* Pure helper — exported for unit testing. Adds `windowsHide: true` on Windows
|
|
8
8
|
* to prevent the spawned shell from creating a visible console window that
|
package/build/executor.js
CHANGED
|
@@ -6,8 +6,10 @@ import { detectRuntimes, buildCommand, } from "./runtime.js";
|
|
|
6
6
|
const isWin = process.platform === "win32";
|
|
7
7
|
/**
|
|
8
8
|
* Pure helper: extension map for temp script files per language.
|
|
9
|
-
* On Windows, shell scripts get NO extension to avoid Windows
|
|
10
|
-
* for `.sh` (which spawns a visible Git Bash window over the
|
|
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.
|
|
11
13
|
*/
|
|
12
14
|
const SCRIPT_EXT = {
|
|
13
15
|
javascript: "js",
|
|
@@ -23,9 +25,13 @@ const SCRIPT_EXT = {
|
|
|
23
25
|
elixir: "exs",
|
|
24
26
|
};
|
|
25
27
|
/** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
|
|
26
|
-
export function buildScriptFilename(language, platform) {
|
|
27
|
-
if (platform === "win32" && language === "shell")
|
|
28
|
-
|
|
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
|
+
}
|
|
29
35
|
return `script.${SCRIPT_EXT[language]}`;
|
|
30
36
|
}
|
|
31
37
|
/**
|
|
@@ -114,7 +120,7 @@ export class PolyglotExecutor {
|
|
|
114
120
|
this.#backgroundedPids.clear();
|
|
115
121
|
}
|
|
116
122
|
async execute(opts) {
|
|
117
|
-
const { language, code, timeout
|
|
123
|
+
const { language, code, timeout, background = false } = opts;
|
|
118
124
|
const tmpDir = mkdtempSync(join(OS_TMPDIR, ".ctx-mode-"));
|
|
119
125
|
try {
|
|
120
126
|
const filePath = this.#writeScript(tmpDir, code, language);
|
|
@@ -146,7 +152,7 @@ export class PolyglotExecutor {
|
|
|
146
152
|
}
|
|
147
153
|
}
|
|
148
154
|
async executeFile(opts) {
|
|
149
|
-
const { path: filePath, language, code, timeout
|
|
155
|
+
const { path: filePath, language, code, timeout } = opts;
|
|
150
156
|
const absolutePath = resolve(this.#projectRoot, filePath);
|
|
151
157
|
const wrappedCode = this.#wrapWithFileContent(absolutePath, language, code);
|
|
152
158
|
return this.execute({ language, code: wrappedCode, timeout });
|
|
@@ -165,7 +171,7 @@ export class PolyglotExecutor {
|
|
|
165
171
|
const escaped = JSON.stringify(join(this.#projectRoot, "_build/dev/lib"));
|
|
166
172
|
code = `Path.wildcard(Path.join(${escaped}, "*/ebin"))\n|> Enum.each(&Code.prepend_path/1)\n\n${code}`;
|
|
167
173
|
}
|
|
168
|
-
const fp = join(tmpDir, buildScriptFilename(language, process.platform));
|
|
174
|
+
const fp = join(tmpDir, buildScriptFilename(language, process.platform, language === "shell" ? this.#runtimes.shell : null));
|
|
169
175
|
if (language === "shell") {
|
|
170
176
|
writeFileSync(fp, code, { encoding: "utf-8", mode: 0o700 });
|
|
171
177
|
}
|
|
@@ -177,11 +183,13 @@ export class PolyglotExecutor {
|
|
|
177
183
|
async #compileAndRun(srcPath, cwd, timeout) {
|
|
178
184
|
const binSuffix = isWin ? ".exe" : "";
|
|
179
185
|
const binPath = srcPath.replace(/\.rs$/, "") + binSuffix;
|
|
180
|
-
// 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).
|
|
181
189
|
try {
|
|
182
190
|
execFileSync("rustc", [srcPath, "-o", binPath], {
|
|
183
191
|
cwd,
|
|
184
|
-
timeout: Math.min(timeout, 60_000),
|
|
192
|
+
timeout: timeout === undefined ? 60_000 : Math.min(timeout, 60_000),
|
|
185
193
|
encoding: "utf-8",
|
|
186
194
|
stdio: ["pipe", "pipe", "pipe"],
|
|
187
195
|
});
|
|
@@ -232,7 +240,12 @@ export class PolyglotExecutor {
|
|
|
232
240
|
});
|
|
233
241
|
let timedOut = false;
|
|
234
242
|
let resolved = false;
|
|
235
|
-
|
|
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(() => {
|
|
236
249
|
timedOut = true;
|
|
237
250
|
if (background) {
|
|
238
251
|
// Background mode: detach process, return partial output, keep running
|
|
@@ -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,12 +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>;
|
|
69
|
-
"experimental.chat.
|
|
70
|
-
messages?: Array<{
|
|
71
|
-
role: string;
|
|
72
|
-
content: string;
|
|
73
|
-
}>;
|
|
74
|
-
} | undefined) => Promise<void>;
|
|
91
|
+
"experimental.chat.system.transform": (input: SystemTransformHookInput, output: SystemTransformHookOutput) => Promise<void>;
|
|
75
92
|
}>;
|
|
76
93
|
declare const _default: {
|
|
77
94
|
server: typeof createContextModePlugin;
|
package/build/opencode-plugin.js
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
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";
|
|
@@ -70,17 +69,18 @@ async function createContextModePlugin(ctx) {
|
|
|
70
69
|
const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
|
|
71
70
|
const routing = await import(pathToFileURL(routingPath).href);
|
|
72
71
|
await routing.initSecurity(buildDir);
|
|
73
|
-
// 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).
|
|
74
76
|
const projectDir = ctx.directory;
|
|
75
77
|
const db = new SessionDB({ dbPath: adapter.getSessionDBPath(projectDir) });
|
|
76
|
-
|
|
77
|
-
db.ensureSession(sessionId, projectDir);
|
|
78
|
-
// Clean up old sessions on startup (replaces SessionStart hook)
|
|
78
|
+
// Clean up old sessions on startup (no SessionStart hook to do this).
|
|
79
79
|
db.cleanupOldSessions(7);
|
|
80
|
-
// Track
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
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();
|
|
84
84
|
return {
|
|
85
85
|
// ── PreToolUse: Routing enforcement ─────────────────
|
|
86
86
|
"tool.execute.before": async (input, output) => {
|
|
@@ -107,7 +107,11 @@ async function createContextModePlugin(ctx) {
|
|
|
107
107
|
},
|
|
108
108
|
// ── PostToolUse: Session event capture ──────────────
|
|
109
109
|
"tool.execute.after": async (input, output) => {
|
|
110
|
+
const sessionId = input.sessionID;
|
|
111
|
+
if (!sessionId)
|
|
112
|
+
return;
|
|
110
113
|
try {
|
|
114
|
+
db.ensureSession(sessionId, projectDir);
|
|
111
115
|
const hookInput = {
|
|
112
116
|
tool_name: input.tool ?? "",
|
|
113
117
|
tool_input: input.args ?? {},
|
|
@@ -126,7 +130,11 @@ async function createContextModePlugin(ctx) {
|
|
|
126
130
|
},
|
|
127
131
|
// ── PreCompact: Snapshot generation ─────────────────
|
|
128
132
|
"experimental.session.compacting": async (input, output) => {
|
|
133
|
+
const sessionId = input.sessionID;
|
|
134
|
+
if (!sessionId)
|
|
135
|
+
return "";
|
|
129
136
|
try {
|
|
137
|
+
db.ensureSession(sessionId, projectDir);
|
|
130
138
|
const events = db.getEvents(sessionId);
|
|
131
139
|
if (events.length === 0)
|
|
132
140
|
return "";
|
|
@@ -145,29 +153,36 @@ async function createContextModePlugin(ctx) {
|
|
|
145
153
|
}
|
|
146
154
|
},
|
|
147
155
|
// ── SessionStart equivalent (PR #376) ───────────────
|
|
148
|
-
// OpenCode lacks a real SessionStart hook (#14808, #5409)
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// most-recent resume snapshot
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
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))
|
|
156
168
|
return;
|
|
157
|
-
|
|
169
|
+
resumeInjected.add(sessionId);
|
|
158
170
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
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)
|
|
171
|
+
const row = db.claimLatestUnconsumedResume();
|
|
172
|
+
if (!row || !row.snapshot)
|
|
165
173
|
return;
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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);
|
|
171
186
|
}
|
|
172
187
|
}
|
|
173
188
|
catch {
|
package/build/server.d.ts
CHANGED
|
@@ -17,7 +17,12 @@ export interface BatchRunResult {
|
|
|
17
17
|
timedOut: boolean;
|
|
18
18
|
}
|
|
19
19
|
export interface BatchRunOptions {
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Total budget (concurrency=1, shared) or per-command (concurrency>1).
|
|
22
|
+
* When `undefined`, no server-side timer fires — the MCP host's RPC
|
|
23
|
+
* timeout governs (Issue #406).
|
|
24
|
+
*/
|
|
25
|
+
timeout: number | undefined;
|
|
21
26
|
concurrency: number;
|
|
22
27
|
nodeOptsPrefix: string;
|
|
23
28
|
onFsBytes?: (bytes: number) => void;
|
|
@@ -26,7 +31,7 @@ interface BatchExecutor {
|
|
|
26
31
|
execute(input: {
|
|
27
32
|
language: "shell";
|
|
28
33
|
code: string;
|
|
29
|
-
timeout: number;
|
|
34
|
+
timeout: number | undefined;
|
|
30
35
|
}): Promise<{
|
|
31
36
|
stdout: string;
|
|
32
37
|
timedOut?: boolean;
|
package/build/server.js
CHANGED
|
@@ -698,22 +698,28 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
698
698
|
const { timeout, concurrency, nodeOptsPrefix, onFsBytes } = opts;
|
|
699
699
|
if (concurrency <= 1) {
|
|
700
700
|
// Serial path — shared timeout budget, cascading skip on timeout.
|
|
701
|
+
// When `timeout` is undefined, no shared budget is enforced; each
|
|
702
|
+
// command runs to completion (Issue #406).
|
|
701
703
|
const outputs = [];
|
|
702
704
|
const startTime = Date.now();
|
|
703
705
|
let timedOut = false;
|
|
704
706
|
for (let i = 0; i < commands.length; i++) {
|
|
705
707
|
const cmd = commands[i];
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
708
|
+
let perCmdTimeout;
|
|
709
|
+
if (timeout !== undefined) {
|
|
710
|
+
const elapsed = Date.now() - startTime;
|
|
711
|
+
const remaining = timeout - elapsed;
|
|
712
|
+
if (remaining <= 0) {
|
|
713
|
+
outputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`);
|
|
714
|
+
timedOut = true;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
perCmdTimeout = remaining;
|
|
712
718
|
}
|
|
713
719
|
const result = await executor.execute({
|
|
714
720
|
language: "shell",
|
|
715
721
|
code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
|
|
716
|
-
timeout:
|
|
722
|
+
timeout: perCmdTimeout,
|
|
717
723
|
});
|
|
718
724
|
outputs.push(formatCommandOutput(cmd.label, result.stdout, onFsBytes));
|
|
719
725
|
if (result.timedOut) {
|
|
@@ -740,7 +746,7 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
740
746
|
// markers are stripped + counted, even when the command timed out.
|
|
741
747
|
const formatted = formatCommandOutput(cmd.label, result.stdout, onFsBytes);
|
|
742
748
|
const output = result.timedOut
|
|
743
|
-
? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout}ms)\n`
|
|
749
|
+
? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
|
|
744
750
|
: formatted;
|
|
745
751
|
return { output, timedOut: !!result.timedOut };
|
|
746
752
|
},
|
|
@@ -791,8 +797,7 @@ server.registerTool("ctx_execute", {
|
|
|
791
797
|
timeout: z
|
|
792
798
|
.coerce.number()
|
|
793
799
|
.optional()
|
|
794
|
-
.
|
|
795
|
-
.describe("Max execution time in ms"),
|
|
800
|
+
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs (which is the right layer for this policy). Pass an explicit value for long-running builds (Gradle/Maven/SBT)."),
|
|
796
801
|
background: z
|
|
797
802
|
.boolean()
|
|
798
803
|
.optional()
|
|
@@ -1087,8 +1092,7 @@ server.registerTool("ctx_execute_file", {
|
|
|
1087
1092
|
timeout: z
|
|
1088
1093
|
.coerce.number()
|
|
1089
1094
|
.optional()
|
|
1090
|
-
.
|
|
1091
|
-
.describe("Max execution time in ms"),
|
|
1095
|
+
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs."),
|
|
1092
1096
|
intent: z
|
|
1093
1097
|
.string()
|
|
1094
1098
|
.optional()
|
|
@@ -2041,8 +2045,7 @@ server.registerTool("ctx_batch_execute", {
|
|
|
2041
2045
|
timeout: z
|
|
2042
2046
|
.coerce.number()
|
|
2043
2047
|
.optional()
|
|
2044
|
-
.
|
|
2045
|
-
.describe("Max execution time in ms (default: 60s). With concurrency=1, shared budget across commands; with concurrency>1, applied per-command."),
|
|
2048
|
+
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs. With concurrency=1, the value (when set) is a shared budget across commands; with concurrency>1, it is applied per-command."),
|
|
2046
2049
|
concurrency: z
|
|
2047
2050
|
.coerce.number()
|
|
2048
2051
|
.int()
|
|
@@ -2218,22 +2221,30 @@ server.registerTool("ctx_stats", {
|
|
|
2218
2221
|
server.registerTool("ctx_doctor", {
|
|
2219
2222
|
title: "Run Diagnostics",
|
|
2220
2223
|
description: "Diagnose context-mode installation. Runs all checks server-side and " +
|
|
2221
|
-
"returns
|
|
2224
|
+
"returns a plain-text status report with [OK]/[FAIL]/[WARN] prefixes " +
|
|
2225
|
+
"(renderer-safe across MCP clients). No CLI execution needed.",
|
|
2222
2226
|
inputSchema: z.object({}),
|
|
2223
2227
|
}, async () => {
|
|
2224
|
-
|
|
2228
|
+
// Renderer-safe output (Mickey #3 — Z.ai GLM 4.7 ReferenceError):
|
|
2229
|
+
// Z.ai's MCP renderer mounts a custom React component for GitHub-flavored
|
|
2230
|
+
// markdown task-list syntax (`- [x]` / `- [ ]` / `- [-]`) that depends on
|
|
2231
|
+
// a missing `client` context, throwing `ReferenceError: client is not
|
|
2232
|
+
// defined`. We avoid both task-list syntax AND `## ` h2 headings to stay
|
|
2233
|
+
// safe across all MCP renderers — using plain-text status prefixes
|
|
2234
|
+
// (`[OK]` / `[FAIL]` / `[WARN]`) instead.
|
|
2235
|
+
const lines = ["context-mode doctor", ""];
|
|
2225
2236
|
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
|
|
2226
2237
|
const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
|
|
2227
2238
|
// Runtimes
|
|
2228
2239
|
const total = 11;
|
|
2229
2240
|
const pct = ((available.length / total) * 100).toFixed(0);
|
|
2230
|
-
lines.push(
|
|
2241
|
+
lines.push(`[OK] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
|
|
2231
2242
|
// Performance
|
|
2232
2243
|
if (hasBunRuntime()) {
|
|
2233
|
-
lines.push("
|
|
2244
|
+
lines.push("[OK] Performance: FAST (Bun)");
|
|
2234
2245
|
}
|
|
2235
2246
|
else {
|
|
2236
|
-
lines.push("
|
|
2247
|
+
lines.push("[WARN] Performance: NORMAL — install Bun for 3-5x speed boost");
|
|
2237
2248
|
}
|
|
2238
2249
|
// Server test — cleanup executor to prevent resource leaks (#247)
|
|
2239
2250
|
{
|
|
@@ -2241,15 +2252,15 @@ server.registerTool("ctx_doctor", {
|
|
|
2241
2252
|
try {
|
|
2242
2253
|
const result = await testExecutor.execute({ language: "javascript", code: 'console.log("ok");', timeout: 5000 });
|
|
2243
2254
|
if (result.exitCode === 0 && result.stdout.trim() === "ok") {
|
|
2244
|
-
lines.push("
|
|
2255
|
+
lines.push("[OK] Server test: PASS");
|
|
2245
2256
|
}
|
|
2246
2257
|
else {
|
|
2247
2258
|
const detail = result.stderr?.trim() ? ` (${result.stderr.trim().slice(0, 200)})` : "";
|
|
2248
|
-
lines.push(
|
|
2259
|
+
lines.push(`[FAIL] Server test: FAIL — exit ${result.exitCode}${detail}`);
|
|
2249
2260
|
}
|
|
2250
2261
|
}
|
|
2251
2262
|
catch (err) {
|
|
2252
|
-
lines.push(
|
|
2263
|
+
lines.push(`[FAIL] Server test: FAIL — ${err instanceof Error ? err.message : err}`);
|
|
2253
2264
|
}
|
|
2254
2265
|
finally {
|
|
2255
2266
|
testExecutor.cleanupBackgrounded();
|
|
@@ -2265,14 +2276,14 @@ server.registerTool("ctx_doctor", {
|
|
|
2265
2276
|
testDb.exec("INSERT INTO fts_test(content) VALUES ('hello world')");
|
|
2266
2277
|
const row = testDb.prepare("SELECT * FROM fts_test WHERE fts_test MATCH 'hello'").get();
|
|
2267
2278
|
if (row && row.content === "hello world") {
|
|
2268
|
-
lines.push("
|
|
2279
|
+
lines.push("[OK] FTS5 / SQLite: PASS — native module works");
|
|
2269
2280
|
}
|
|
2270
2281
|
else {
|
|
2271
|
-
lines.push("
|
|
2282
|
+
lines.push("[FAIL] FTS5 / SQLite: FAIL — unexpected result");
|
|
2272
2283
|
}
|
|
2273
2284
|
}
|
|
2274
2285
|
catch (err) {
|
|
2275
|
-
lines.push(
|
|
2286
|
+
lines.push(`[FAIL] FTS5 / SQLite: FAIL — ${err instanceof Error ? err.message : err}`);
|
|
2276
2287
|
}
|
|
2277
2288
|
finally {
|
|
2278
2289
|
try {
|
|
@@ -2284,13 +2295,13 @@ server.registerTool("ctx_doctor", {
|
|
|
2284
2295
|
// Hook script
|
|
2285
2296
|
const hookPath = resolve(pluginRoot, "hooks", "pretooluse.mjs");
|
|
2286
2297
|
if (existsSync(hookPath)) {
|
|
2287
|
-
lines.push(
|
|
2298
|
+
lines.push(`[OK] Hook script: PASS — ${hookPath}`);
|
|
2288
2299
|
}
|
|
2289
2300
|
else {
|
|
2290
|
-
lines.push(
|
|
2301
|
+
lines.push(`[FAIL] Hook script: FAIL — not found at ${hookPath}`);
|
|
2291
2302
|
}
|
|
2292
2303
|
// Version
|
|
2293
|
-
lines.push(
|
|
2304
|
+
lines.push(`[OK] Version: v${VERSION}`);
|
|
2294
2305
|
return trackResponse("ctx_doctor", {
|
|
2295
2306
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
2296
2307
|
});
|
|
@@ -2521,14 +2532,22 @@ server.registerTool("ctx_insight", {
|
|
|
2521
2532
|
"First run installs dependencies (~30s). Subsequent runs open instantly.",
|
|
2522
2533
|
inputSchema: z.object({
|
|
2523
2534
|
port: z.coerce.number().optional().describe("Port to serve on (default: 4747)"),
|
|
2535
|
+
sessionDir: z.string().optional().describe("Override INSIGHT_SESSION_DIR: directory containing context-mode session .db files"),
|
|
2536
|
+
contentDir: z.string().optional().describe("Override INSIGHT_CONTENT_DIR: directory containing context-mode content/index .db files"),
|
|
2537
|
+
insightSessionDir: z.string().optional().describe("Alias for sessionDir / INSIGHT_SESSION_DIR"),
|
|
2538
|
+
insightContentDir: z.string().optional().describe("Alias for contentDir / INSIGHT_CONTENT_DIR"),
|
|
2524
2539
|
}),
|
|
2525
|
-
}, async ({ port: userPort }) => {
|
|
2540
|
+
}, async ({ port: userPort, sessionDir, contentDir, insightSessionDir, insightContentDir }) => {
|
|
2526
2541
|
const port = userPort || 4747;
|
|
2542
|
+
const explicitSessionDir = sessionDir || insightSessionDir;
|
|
2543
|
+
const explicitContentDir = contentDir || insightContentDir;
|
|
2527
2544
|
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
|
|
2528
2545
|
const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
|
|
2529
2546
|
const insightSource = resolve(pluginRoot, "insight");
|
|
2530
|
-
// Use adapter-aware path
|
|
2531
|
-
|
|
2547
|
+
// Use adapter-aware path by default, but allow MCP callers to pass explicit
|
|
2548
|
+
// Insight data dirs for hosts whose adapter/default detection is unavailable.
|
|
2549
|
+
const sessDir = explicitSessionDir ? resolve(explicitSessionDir) : getSessionDir();
|
|
2550
|
+
const insightContentDirResolved = explicitContentDir ? resolve(explicitContentDir) : join(dirname(sessDir), "content");
|
|
2532
2551
|
const cacheDir = join(dirname(sessDir), "insight-cache");
|
|
2533
2552
|
// Verify source exists
|
|
2534
2553
|
if (!existsSync(join(insightSource, "server.mjs"))) {
|
|
@@ -2653,8 +2672,8 @@ server.registerTool("ctx_insight", {
|
|
|
2653
2672
|
env: {
|
|
2654
2673
|
...process.env,
|
|
2655
2674
|
PORT: String(port),
|
|
2656
|
-
INSIGHT_SESSION_DIR:
|
|
2657
|
-
INSIGHT_CONTENT_DIR:
|
|
2675
|
+
INSIGHT_SESSION_DIR: sessDir,
|
|
2676
|
+
INSIGHT_CONTENT_DIR: insightContentDirResolved,
|
|
2658
2677
|
INSIGHT_PARENT_PID: String(process.pid),
|
|
2659
2678
|
},
|
|
2660
2679
|
detached: true,
|
package/build/session/db.d.ts
CHANGED
|
@@ -148,6 +148,20 @@ export declare class SessionDB extends SQLiteBase {
|
|
|
148
148
|
* Mark the resume snapshot as consumed (already injected into conversation).
|
|
149
149
|
*/
|
|
150
150
|
markResumeConsumed(sessionId: string): void;
|
|
151
|
+
/**
|
|
152
|
+
* Atomically claim the most recent unconsumed resume snapshot in this DB.
|
|
153
|
+
*
|
|
154
|
+
* `SessionDB` is sharded per project (see `getSessionDBPath` — SHA-256 of
|
|
155
|
+
* project dir), so "this DB" already implies "this project". The atomic
|
|
156
|
+
* `UPDATE … RETURNING` ensures concurrent processes for the same project
|
|
157
|
+
* cannot both inject the same snapshot (Mickey / PR #376 race).
|
|
158
|
+
*
|
|
159
|
+
* Returns null when no unconsumed snapshot exists.
|
|
160
|
+
*/
|
|
161
|
+
claimLatestUnconsumedResume(): {
|
|
162
|
+
sessionId: string;
|
|
163
|
+
snapshot: string;
|
|
164
|
+
} | null;
|
|
151
165
|
/**
|
|
152
166
|
* Return the most recent session_id from session_meta, or null if none.
|
|
153
167
|
* Used by the runtime to attach persistent counters to the right session
|