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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.104"
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.104",
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.104",
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.104",
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.104",
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.
@@ -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 file-association
10
- * for `.sh` (which spawns a visible Git Bash window over the user's IDE).
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
- return "script";
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 = 30_000, background = false } = opts;
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 = 30_000 } = opts;
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
- const timer = setTimeout(() => {
243
+ // Issue #406 if the caller didn't pass a timeout we don't fire one.
244
+ // Timeout policy belongs to the MCP host/client (Claude Code, VSCode,
245
+ // JetBrains all enforce their own RPC timeouts); imposing a second
246
+ // policy here turned 30-minute Gradle/Maven/SBT builds into spurious
247
+ // false negatives whenever the caller forgot the explicit value.
248
+ const timer = timeout === undefined ? undefined : setTimeout(() => {
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.messages.transform": (_input: unknown, output: {
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;
@@ -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 session
72
+ // Initialize per-process state. We do NOT fabricate a sessionId here —
73
+ // OpenCode/Kilo provide the real `input.sessionID` on every hook, and a
74
+ // process-global UUID would (a) never match prior-session resume rows and
75
+ // (b) collide across multi-session reuse (Mickey / PR #376 root cause).
74
76
  const projectDir = ctx.directory;
75
77
  const db = new SessionDB({ dbPath: adapter.getSessionDBPath(projectDir) });
76
- const sessionId = randomUUID();
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 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;
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) 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
+ // 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
- sessionStartInjected = true;
169
+ resumeInjected.add(sessionId);
158
170
  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)
171
+ const row = db.claimLatestUnconsumedResume();
172
+ if (!row || !row.snapshot)
165
173
  return;
166
- if (output && Array.isArray(output.messages)) {
167
- output.messages.unshift({
168
- role: "system",
169
- content: snapshot,
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
- timeout: number;
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
- const elapsed = Date.now() - startTime;
707
- const remaining = timeout - elapsed;
708
- if (remaining <= 0) {
709
- outputs.push(`# ${cmd.label}\n\n(skipped batch timeout exceeded)\n`);
710
- timedOut = true;
711
- continue;
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: remaining,
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
- .default(30000)
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
- .default(30000)
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
- .default(60000)
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 results as a markdown checklist. No CLI execution needed.",
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
- const lines = ["## context-mode doctor", ""];
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(`- [x] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
2241
+ lines.push(`[OK] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
2231
2242
  // Performance
2232
2243
  if (hasBunRuntime()) {
2233
- lines.push("- [x] Performance: FAST (Bun)");
2244
+ lines.push("[OK] Performance: FAST (Bun)");
2234
2245
  }
2235
2246
  else {
2236
- lines.push("- [-] Performance: NORMAL — install Bun for 3-5x speed boost");
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("- [x] Server test: PASS");
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(`- [ ] Server test: FAIL — exit ${result.exitCode}${detail}`);
2259
+ lines.push(`[FAIL] Server test: FAIL — exit ${result.exitCode}${detail}`);
2249
2260
  }
2250
2261
  }
2251
2262
  catch (err) {
2252
- lines.push(`- [ ] Server test: FAIL — ${err instanceof Error ? err.message : err}`);
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("- [x] FTS5 / SQLite: PASS — native module works");
2279
+ lines.push("[OK] FTS5 / SQLite: PASS — native module works");
2269
2280
  }
2270
2281
  else {
2271
- lines.push("- [ ] FTS5 / SQLite: FAIL — unexpected result");
2282
+ lines.push("[FAIL] FTS5 / SQLite: FAIL — unexpected result");
2272
2283
  }
2273
2284
  }
2274
2285
  catch (err) {
2275
- lines.push(`- [ ] FTS5 / SQLite: FAIL — ${err instanceof Error ? err.message : err}`);
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(`- [x] Hook script: PASS — ${hookPath}`);
2298
+ lines.push(`[OK] Hook script: PASS — ${hookPath}`);
2288
2299
  }
2289
2300
  else {
2290
- lines.push(`- [ ] Hook script: FAIL — not found at ${hookPath}`);
2301
+ lines.push(`[FAIL] Hook script: FAIL — not found at ${hookPath}`);
2291
2302
  }
2292
2303
  // Version
2293
- lines.push(`- [x] Version: v${VERSION}`);
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: derive from sessions dir (works across all 12 adapters)
2531
- const sessDir = getSessionDir();
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: getSessionDir(),
2657
- INSIGHT_CONTENT_DIR: join(dirname(getSessionDir()), "content"),
2675
+ INSIGHT_SESSION_DIR: sessDir,
2676
+ INSIGHT_CONTENT_DIR: insightContentDirResolved,
2658
2677
  INSIGHT_PARENT_PID: String(process.pid),
2659
2678
  },
2660
2679
  detached: true,
@@ -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