context-mode 1.0.104 → 1.0.106

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.106"
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.106",
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.106",
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.106",
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.106",
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,14 +18,29 @@
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";
23
+ import { existsSync, readFileSync } from "node:fs";
24
24
  import { SessionDB } from "./session/db.js";
25
25
  import { extractEvents } from "./session/extract.js";
26
26
  import { buildResumeSnapshot } from "./session/snapshot.js";
27
27
  import { OpenCodeAdapter } from "./adapters/opencode/index.js";
28
28
  import { PLATFORM_ENV_VARS } from "./adapters/detect.js";
29
+ // Read package.json version once at module load (not on every hook call).
30
+ // Used in the resume-injection visible signal so users can confirm in
31
+ // OPENCODE_DEBUG logs which plugin version actually injected.
32
+ const VERSION = (() => {
33
+ try {
34
+ const pkgRoot = dirname(fileURLToPath(import.meta.url));
35
+ for (const rel of ["../package.json", "./package.json"]) {
36
+ const p = resolve(pkgRoot, rel);
37
+ if (existsSync(p))
38
+ return JSON.parse(readFileSync(p, "utf8")).version ?? "unknown";
39
+ }
40
+ }
41
+ catch { /* fall through */ }
42
+ return "unknown";
43
+ })();
29
44
  // ── Helpers ───────────────────────────────────────────────
30
45
  /**
31
46
  * Detect whether the plugin is running under KiloCode or OpenCode.
@@ -70,17 +85,18 @@ async function createContextModePlugin(ctx) {
70
85
  const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
71
86
  const routing = await import(pathToFileURL(routingPath).href);
72
87
  await routing.initSecurity(buildDir);
73
- // Initialize session
88
+ // Initialize per-process state. We do NOT fabricate a sessionId here —
89
+ // OpenCode/Kilo provide the real `input.sessionID` on every hook, and a
90
+ // process-global UUID would (a) never match prior-session resume rows and
91
+ // (b) collide across multi-session reuse (Mickey / PR #376 root cause).
74
92
  const projectDir = ctx.directory;
75
93
  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)
94
+ // Clean up old sessions on startup (no SessionStart hook to do this).
79
95
  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;
96
+ // Track per-session resume injection: persistent plugin process can host
97
+ // many sessions, so the gate must be keyed by sessionID NOT a single
98
+ // boolean closure flag (Mickey #2 root cause).
99
+ const resumeInjected = new Set();
84
100
  return {
85
101
  // ── PreToolUse: Routing enforcement ─────────────────
86
102
  "tool.execute.before": async (input, output) => {
@@ -107,7 +123,11 @@ async function createContextModePlugin(ctx) {
107
123
  },
108
124
  // ── PostToolUse: Session event capture ──────────────
109
125
  "tool.execute.after": async (input, output) => {
126
+ const sessionId = input.sessionID;
127
+ if (!sessionId)
128
+ return;
110
129
  try {
130
+ db.ensureSession(sessionId, projectDir);
111
131
  const hookInput = {
112
132
  tool_name: input.tool ?? "",
113
133
  tool_input: input.args ?? {},
@@ -126,7 +146,11 @@ async function createContextModePlugin(ctx) {
126
146
  },
127
147
  // ── PreCompact: Snapshot generation ─────────────────
128
148
  "experimental.session.compacting": async (input, output) => {
149
+ const sessionId = input.sessionID;
150
+ if (!sessionId)
151
+ return "";
129
152
  try {
153
+ db.ensureSession(sessionId, projectDir);
130
154
  const events = db.getEvents(sessionId);
131
155
  if (events.length === 0)
132
156
  return "";
@@ -145,29 +169,47 @@ async function createContextModePlugin(ctx) {
145
169
  }
146
170
  },
147
171
  // ── 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)
172
+ // OpenCode lacks a real SessionStart hook (#14808, #5409). The closest
173
+ // surrogate is `experimental.chat.system.transform` — verified shape:
174
+ // input: { sessionID?: string; model: Model }
175
+ // output: { system: string[] }
176
+ // We claim the most-recent unconsumed resume snapshot atomically (race-
177
+ // safe across concurrent processes) and prepend it to the system prompt.
178
+ // First-injection-per-session is enforced by `resumeInjected` Set.
179
+ "experimental.chat.system.transform": async (input, output) => {
180
+ const sessionId = input?.sessionID;
181
+ if (!sessionId)
182
+ return;
183
+ if (resumeInjected.has(sessionId))
156
184
  return;
157
- sessionStartInjected = true;
158
185
  try {
159
- // Find the most recent resume snapshot for this project across
160
- // any prior session. ContextSessionDB has no per-project resume
161
- // lookup, so we fall back to the current session's resume row.
162
- const row = db.getResume(sessionId);
163
- const snapshot = row?.snapshot;
164
- if (!snapshot || snapshot.length === 0)
165
- return;
166
- if (output && Array.isArray(output.messages)) {
167
- output.messages.unshift({
168
- role: "system",
169
- content: snapshot,
170
- });
186
+ // Pass current sessionId so SQL excludes self-injection (v1.0.106 Mickey #376
187
+ // follow-up): if Session B compacts mid-flight and produces its own row,
188
+ // B's next system.transform must NOT claim that row back into B's prompt.
189
+ const row = db.claimLatestUnconsumedResume(sessionId);
190
+ if (!row || !row.snapshot)
191
+ return; // no row leave `resumeInjected` unset → retry on next turn
192
+ if (Array.isArray(output?.system)) {
193
+ // Visible signal without this, the injection is silent and users
194
+ // cannot tell the feature is active (Mickey: "I can't find use case
195
+ // for it"). The XML comment is harmless to the model and shows up in
196
+ // OPENCODE_DEBUG logs as proof the snapshot landed.
197
+ const eventCount = row.snapshot.match(/events="(\d+)"/)?.[1] ?? "?";
198
+ const marker = `<!-- context-mode v${VERSION}: resumed prior session ${row.sessionId.slice(0, 8)} ` +
199
+ `(${eventCount} events, ${row.snapshot.length} chars) -->\n`;
200
+ // Insert at index 1 (after the header) — NOT unshift.
201
+ // OpenCode's llm.ts:117-128 saves `header = system[0]` BEFORE this
202
+ // hook runs and then folds the rest into a 2-part structure
203
+ // `[header, body]` only if `system[0] === header` after the hook.
204
+ // Prepending via unshift replaces system[0] with the snapshot,
205
+ // making the equality check fail → cache-fold is skipped → every
206
+ // system block is sent as a separate `role: "system"` message →
207
+ // provider prompt cache is invalidated on every resume injection.
208
+ // Inserting at index 1 keeps the header invariant and lets the
209
+ // snapshot ride along inside the cached body block.
210
+ output.system.splice(1, 0, marker + row.snapshot);
211
+ // Mark consumed only AFTER successful splice so failed paths can retry
212
+ resumeInjected.add(sessionId);
171
213
  }
172
214
  }
173
215
  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,
@@ -2786,9 +2805,17 @@ async function main() {
2786
2805
  }
2787
2806
  }
2788
2807
  catch { /* best effort — _detectedAdapter stays null, falls back to .claude */ }
2789
- // Non-blocking version check — result stored for trackResponse warnings
2808
+ // Non-blocking version check — result stored for trackResponse warnings.
2809
+ // First fetch at startup, then refresh every hour so long-running sessions
2810
+ // (some users keep the MCP server alive 24h+) catch new releases without a
2811
+ // restart. `.unref()` lets the process exit normally on SIGTERM regardless
2812
+ // of pending intervals.
2790
2813
  fetchLatestVersion().then(v => { if (v !== "unknown")
2791
2814
  _latestVersion = v; });
2815
+ setInterval(() => {
2816
+ fetchLatestVersion().then(v => { if (v !== "unknown")
2817
+ _latestVersion = v; });
2818
+ }, 60 * 60 * 1000).unref();
2792
2819
  console.error(`Context Mode MCP server v${VERSION} running on stdio`);
2793
2820
  console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
2794
2821
  if (!hasBunRuntime()) {