context-mode 1.0.140 → 1.0.142

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.140"
9
+ "version": "1.0.142"
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.140",
16
+ "version": "1.0.142",
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.140",
3
+ "version": "1.0.142",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.140",
3
+ "version": "1.0.142",
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.140",
6
+ "version": "1.0.142",
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.140",
3
+ "version": "1.0.142",
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
@@ -439,7 +439,7 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
439
439
 
440
440
  **Verify:** In the OpenCode session, type `ctx stats`. Context-mode tools should appear and respond.
441
441
 
442
- **Upgrade note:** If an existing config still has `mcp.context-mode`, run `context-mode upgrade`. OpenCode now gets `ctx_*` tools from the plugin; the upgrade removes only `mcp.context-mode` and preserves any other MCP servers.
442
+ **Upgrade note:** If an existing config has BOTH `plugin: ["context-mode"]` AND `mcp.context-mode`, OpenCode will register zero `ctx_*` tools the plugin path correctly suppresses MCP duplicates, but the legacy MCP entry confuses the loader. Run `context-mode upgrade` to remove the legacy `mcp.context-mode` entry; your other MCP servers are preserved. v1.0.140+ emits a stderr diagnostic with the same guidance when this happens.
443
443
 
444
444
  **Routing:** Hooks enforce routing programmatically via `tool.execute.before` and `tool.execute.after`. The optional [`AGENTS.md`](configs/opencode/AGENTS.md) file provides routing instructions for model awareness. The `experimental.session.compacting` hook builds resume snapshots when the conversation compacts. The `experimental.chat.system.transform` hook injects the routing block and prior-session snapshots at session start, enabling session continuity across restarts. The `chat.message` hook captures user prompts and decisions (UserPromptSubmit equivalent).
445
445
 
@@ -483,7 +483,7 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
483
483
 
484
484
  **Verify:** In the KiloCode session, type `ctx stats`. Context-mode tools should appear and respond.
485
485
 
486
- **Upgrade note:** If an existing config still has `mcp.context-mode`, run `context-mode upgrade`. KiloCode now gets `ctx_*` tools from the plugin; the upgrade removes only `mcp.context-mode` and preserves any other MCP servers.
486
+ **Upgrade note:** If an existing config has BOTH `plugin: ["context-mode"]` AND `mcp.context-mode`, KiloCode will register zero `ctx_*` tools the plugin path correctly suppresses MCP duplicates, but the legacy MCP entry confuses the loader. Run `context-mode upgrade` to remove the legacy `mcp.context-mode` entry; your other MCP servers are preserved. v1.0.140+ emits a stderr diagnostic with the same guidance when this happens.
487
487
 
488
488
  **Routing:** Hooks enforce routing programmatically via `tool.execute.before` and `tool.execute.after`. The optional [`AGENTS.md`](configs/opencode/AGENTS.md) file provides routing instructions for model awareness. The `experimental.session.compacting` hook builds resume snapshots when the conversation compacts. The `experimental.chat.system.transform` hook injects the routing block and prior-session snapshots at session start, enabling session continuity across restarts. The `chat.message` hook captures user prompts and decisions (UserPromptSubmit equivalent).
489
489
 
@@ -28,25 +28,7 @@ import { extractEvents, extractUserEvents } from "../../session/extract.js";
28
28
  import { buildResumeSnapshot } from "../../session/snapshot.js";
29
29
  import { OpenCodeAdapter } from "./index.js";
30
30
  import { PLATFORM_ENV_VARS } from "../detect.js";
31
- // Read package.json version once at module load (not on every hook call).
32
- // Used in the resume-injection visible signal so users can confirm in
33
- // OPENCODE_DEBUG logs which plugin version actually injected.
34
- const VERSION = (() => {
35
- try {
36
- const pkgRoot = dirname(fileURLToPath(import.meta.url));
37
- // Search both the legacy depths (when bundled flat under build/) and
38
- // the post-refactor depths (when compiled to build/adapters/opencode/).
39
- // `../../../package.json` is the canonical location after the
40
- // `src/opencode-plugin.ts → src/adapters/opencode/plugin.ts` move.
41
- for (const rel of ["../../../package.json", "../package.json", "./package.json"]) {
42
- const p = resolve(pkgRoot, rel);
43
- if (existsSync(p))
44
- return JSON.parse(readFileSync(p, "utf8")).version ?? "unknown";
45
- }
46
- }
47
- catch { /* fall through */ }
48
- return "unknown";
49
- })();
31
+ import { zod3ShapeToV4 } from "./zod3tov4.js";
50
32
  // Synthetic message tags emitted by harnesses (CCv2 inline filter). When the
51
33
  // user "message" is actually a system-generated nudge (e.g. tool-result, system
52
34
  // reminder), capturing it as user_prompt would flood the DB with noise.
@@ -264,9 +246,12 @@ async function createContextModePlugin(ctx) {
264
246
  : typeof inputSchema?._def?.shape === "function"
265
247
  ? inputSchema._def.shape()
266
248
  : {};
249
+ const argsForHost = platform === "kilo"
250
+ ? zod3ShapeToV4(shape)
251
+ : shape;
267
252
  tools[registered.name] = {
268
253
  description: String(config.description ?? ""),
269
- args: shape,
254
+ args: argsForHost,
270
255
  async execute(args, toolCtx) {
271
256
  toolCtx.metadata?.({ title: String(config.title ?? registered.name) });
272
257
  const project = toolCtx.directory || projectDir;
@@ -0,0 +1 @@
1
+ export declare function zod3ShapeToV4(shape: Record<string, unknown>, depth?: number): Record<string, unknown>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Zod 3 → Zod 4 shape conversion (KiloCode only).
3
+ *
4
+ * KiloCode's runtime bundles Zod v4 internally. When it receives plugin tool
5
+ * definitions whose `args` contain Zod v3 schemas (with `_def` but no `_zod`),
6
+ * it crashes with `undefined is not an object (evaluating 'n._zod.def')`.
7
+ *
8
+ * This module converts Zod 3 schema shapes into Zod 4 equivalents so KiloCode
9
+ * can process them natively. Only called when `platform === "kilo"`.
10
+ * OpenCode uses Zod 3 natively and receives the original shapes unchanged.
11
+ */
12
+ import z from 'zod/v4';
13
+ export function zod3ShapeToV4(shape, depth = 0) {
14
+ const result = {};
15
+ for (const [key, value] of Object.entries(shape)) {
16
+ result[key] = zod3ToV4(value, depth);
17
+ }
18
+ return result;
19
+ }
20
+ function zod3ToV4(v, depth = 0) {
21
+ if (depth > 10)
22
+ return z.unknown();
23
+ if (v == null || typeof v !== "object")
24
+ return z.unknown();
25
+ const obj = v;
26
+ if (!obj._def || typeof obj._def !== "object")
27
+ return z.unknown();
28
+ const def = obj._def;
29
+ let result;
30
+ switch (def.typeName) {
31
+ case "ZodString":
32
+ result = z.string();
33
+ break;
34
+ case "ZodNumber":
35
+ result = z.number();
36
+ break;
37
+ case "ZodBoolean":
38
+ result = z.boolean();
39
+ break;
40
+ case "ZodAny":
41
+ result = z.any();
42
+ break;
43
+ case "ZodUnknown":
44
+ result = z.unknown();
45
+ break;
46
+ case "ZodNever":
47
+ result = z.never();
48
+ break;
49
+ case "ZodNull":
50
+ result = z.null();
51
+ break;
52
+ case "ZodUndefined":
53
+ result = z.undefined();
54
+ break;
55
+ case "ZodLiteral":
56
+ result = z.literal(def.value);
57
+ break;
58
+ case "ZodArray":
59
+ result = z.array(zod3ToV4(def.type ?? def.elementType, depth + 1));
60
+ break;
61
+ case "ZodEnum": {
62
+ const values = def.values;
63
+ result = Array.isArray(values) && values.length > 0
64
+ ? z.enum(values)
65
+ : z.never();
66
+ break;
67
+ }
68
+ case "ZodObject": {
69
+ const raw = def.shape;
70
+ const inner = typeof raw === "function" ? raw() : raw;
71
+ result = z.object(inner ? zod3ShapeToV4(inner, depth + 1) : {});
72
+ break;
73
+ }
74
+ case "ZodOptional":
75
+ result = z.optional(zod3ToV4(def.innerType ?? def.type, depth + 1));
76
+ break;
77
+ case "ZodNullable":
78
+ result = z.nullable(zod3ToV4(def.innerType ?? def.type, depth + 1));
79
+ break;
80
+ case "ZodDefault": {
81
+ const val = typeof def.defaultValue === "function"
82
+ ? def.defaultValue()
83
+ : def.defaultValue;
84
+ result = zod3ToV4(def.innerType ?? def.type, depth + 1).default(val);
85
+ break;
86
+ }
87
+ case "ZodRecord":
88
+ result = z.record(z.string(), zod3ToV4(def.valueType, depth + 1));
89
+ break;
90
+ case "ZodUnion": {
91
+ const opts = def.options;
92
+ if (!opts || opts.length === 0)
93
+ return z.never();
94
+ if (opts.length === 1)
95
+ return zod3ToV4(opts[0], depth + 1);
96
+ result = z.union(opts.map(o => zod3ToV4(o, depth + 1)));
97
+ break;
98
+ }
99
+ case "ZodEffects":
100
+ // Host schema only. Original Zod 3 schema still parses in execute().
101
+ result = zod3ToV4(def.schema, depth + 1);
102
+ break;
103
+ default:
104
+ // Never leak raw Zod 3 schemas back to KiloCode.
105
+ result = z.unknown();
106
+ break;
107
+ }
108
+ return def.description && typeof result.describe === "function"
109
+ ? result.describe(String(def.description))
110
+ : result;
111
+ }
@@ -10,6 +10,34 @@
10
10
  * Lifecycle: session_start, tool_call, tool_result, before_agent_start,
11
11
  * session_before_compact, session_compact, session_shutdown.
12
12
  */
13
+ /**
14
+ * Strip heredoc + single-quoted + double-quoted content from a shell command
15
+ * so the routing regex only sees command tokens, not user-provided strings.
16
+ *
17
+ * Mirrors hooks/core/routing.mjs:196–209. Inlined here because the Pi
18
+ * extension is bundled as a standalone build artifact (.pi/extensions/...)
19
+ * and cannot import hooks/core/* at runtime — they live in a sibling tree
20
+ * and may not be present in every Pi installation.
21
+ *
22
+ * Exported for unit tests.
23
+ */
24
+ export declare function stripQuotedContent(cmd: string): string;
25
+ /**
26
+ * Returns true iff `segment` is a curl/wget invocation that is SAFE to allow
27
+ * through the Pi routing block — i.e. it cannot flood the model's context
28
+ * window because the response body is written to disk (or appended to a file)
29
+ * and no verbose/trace flag is dumping headers to stderr.
30
+ *
31
+ * Mirrors hooks/core/routing.mjs:672–701. Segments that are NOT curl/wget
32
+ * return `true` (nothing to evaluate). The caller is expected to split chained
33
+ * commands on `&&`, `||`, `;` and call this per segment.
34
+ *
35
+ * Issue #625 — without this, the only escape hatch when the MCP bridge dies
36
+ * is `gh` CLI or a full Pi restart. Neither is acceptable as baseline UX.
37
+ *
38
+ * Exported for unit tests.
39
+ */
40
+ export declare function isSafeCurlWget(segment: string): boolean;
13
41
  /**
14
42
  * Settles when the MCP bridge bootstrap has finished — resolves on
15
43
  * success AND on failure (the bootstrap is best-effort; failures are
@@ -33,9 +33,22 @@ const PI_TOOL_MAP = {
33
33
  };
34
34
  // ── Routing patterns ─────────────────────────────────────
35
35
  // Inline HTTP client patterns to block in bash — self-contained, no routing module needed.
36
- const BLOCKED_BASH_PATTERNS = [
37
- /\bcurl\s/,
38
- /\bwget\s/,
36
+ //
37
+ // Issue #625 — split into two classes so we can apply different policies:
38
+ //
39
+ // * BLOCKED_HTTP_PATTERNS — language-level HTTP calls (fetch, requests, http,
40
+ // urllib, Invoke-WebRequest). These always flood context with raw response
41
+ // bodies, so they remain unconditionally blocked.
42
+ //
43
+ // * curl / wget — handled separately by isSafeCurlWget() below. Mirrors the
44
+ // reference logic in hooks/core/routing.mjs:660–722. Silent + file-output
45
+ // forms are allowed as an MCP-down escape hatch (the body never enters
46
+ // context). Unsafe forms (stdout, verbose, missing file output) still
47
+ // block. This prevents an unrecoverable session trap when the bridge dies.
48
+ //
49
+ // Both are evaluated AFTER stripQuotedContent() so quoted CLI arguments
50
+ // (e.g. `gh issue list --search "curl wget"`) no longer false-positive.
51
+ const BLOCKED_HTTP_PATTERNS = [
39
52
  /\bfetch\s*\(/,
40
53
  /\brequests\.get\s*\(/,
41
54
  /\brequests\.post\s*\(/,
@@ -44,6 +57,68 @@ const BLOCKED_BASH_PATTERNS = [
44
57
  /\burllib\.request/,
45
58
  /\bInvoke-WebRequest\b/,
46
59
  ];
60
+ /**
61
+ * Strip heredoc + single-quoted + double-quoted content from a shell command
62
+ * so the routing regex only sees command tokens, not user-provided strings.
63
+ *
64
+ * Mirrors hooks/core/routing.mjs:196–209. Inlined here because the Pi
65
+ * extension is bundled as a standalone build artifact (.pi/extensions/...)
66
+ * and cannot import hooks/core/* at runtime — they live in a sibling tree
67
+ * and may not be present in every Pi installation.
68
+ *
69
+ * Exported for unit tests.
70
+ */
71
+ export function stripQuotedContent(cmd) {
72
+ return cmd
73
+ .replace(/<<-?\s*["']?(\w+)["']?[\s\S]*?\n\s*\1/g, "") // heredocs
74
+ .replace(/'[^']*'/g, "''") // single-quoted
75
+ .replace(/"[^"]*"/g, '""'); // double-quoted
76
+ }
77
+ /**
78
+ * Returns true iff `segment` is a curl/wget invocation that is SAFE to allow
79
+ * through the Pi routing block — i.e. it cannot flood the model's context
80
+ * window because the response body is written to disk (or appended to a file)
81
+ * and no verbose/trace flag is dumping headers to stderr.
82
+ *
83
+ * Mirrors hooks/core/routing.mjs:672–701. Segments that are NOT curl/wget
84
+ * return `true` (nothing to evaluate). The caller is expected to split chained
85
+ * commands on `&&`, `||`, `;` and call this per segment.
86
+ *
87
+ * Issue #625 — without this, the only escape hatch when the MCP bridge dies
88
+ * is `gh` CLI or a full Pi restart. Neither is acceptable as baseline UX.
89
+ *
90
+ * Exported for unit tests.
91
+ */
92
+ export function isSafeCurlWget(segment) {
93
+ const s = segment.trim();
94
+ const isCurl = /\bcurl\b/i.test(s);
95
+ const isWget = /\bwget\b/i.test(s);
96
+ if (!isCurl && !isWget)
97
+ return true; // not curl/wget — nothing to evaluate
98
+ // Check for file output flags (-o file / --output file for curl,
99
+ // -O file / --output-document file for wget) OR shell redirection (> / >>).
100
+ const hasFileOutput = isCurl
101
+ ? /\s(-o|--output)\s/.test(s) || /\s>\s*/.test(s) || /\s>>\s*/.test(s)
102
+ : /\s(-O|--output-document)\s/.test(s) ||
103
+ /\s>\s*/.test(s) ||
104
+ /\s>>\s*/.test(s);
105
+ if (!hasFileOutput)
106
+ return false; // no file output → body flows to stdout
107
+ // Stdout aliases: -o -, -o /dev/stdout, -O -, -O /dev/stdout.
108
+ if (isCurl && /\s(-o|--output)\s+(-|\/dev\/stdout)(\s|$)/.test(s))
109
+ return false;
110
+ if (isWget && /\s(-O|--output-document)\s+(-|\/dev\/stdout)(\s|$)/.test(s))
111
+ return false;
112
+ // Verbose/trace flags dump request+response headers to stderr → context.
113
+ if (/\s(-v|--verbose|--trace)\b/.test(s))
114
+ return false;
115
+ // Must be silent (curl: -s/--silent, wget: -q/--quiet) so the progress bar
116
+ // does not spill into stderr → context.
117
+ const isSilent = isCurl
118
+ ? /\s-[a-zA-Z]*s|--silent/.test(s)
119
+ : /\s-[a-zA-Z]*q|--quiet/.test(s);
120
+ return isSilent;
121
+ }
47
122
  // ── Module-level DB singleton ────────────────────────────
48
123
  let _db = null;
49
124
  let _sessionId = "";
@@ -318,14 +393,36 @@ export default function piExtension(pi) {
318
393
  const command = String(event?.input?.command ?? "");
319
394
  if (!command)
320
395
  return;
321
- const isBlocked = BLOCKED_BASH_PATTERNS.some((p) => p.test(command));
322
- if (isBlocked) {
396
+ // Issue #625 strip quoted content first so words like `curl` inside
397
+ // a `gh issue list --search "...curl..."` argument do not false-positive.
398
+ const stripped = stripQuotedContent(command);
399
+ // Language-level HTTP calls (fetch, requests, http, urllib,
400
+ // Invoke-WebRequest) always flood context — block unconditionally.
401
+ if (BLOCKED_HTTP_PATTERNS.some((p) => p.test(stripped))) {
323
402
  return {
324
403
  block: true,
325
404
  reason: "Use context-mode MCP tools (execute, fetch_and_index) instead of inline HTTP clients. " +
326
- "Raw curl/wget/fetch output floods the context window.",
405
+ "Raw fetch/requests/http output floods the context window.",
327
406
  };
328
407
  }
408
+ // curl / wget — split chained command on &&, ||, ; and evaluate each
409
+ // segment with isSafeCurlWget(). Allowed if EVERY curl/wget segment is
410
+ // silent + file-output + no verbose + no stdout alias. This preserves
411
+ // the "do not flood context" intent while leaving a recovery path open
412
+ // when the MCP bridge is unreachable.
413
+ if (/(^|\s|&&|\||\;)(curl|wget)\s/i.test(stripped)) {
414
+ const segments = stripped.split(/\s*(?:&&|\|\||;)\s*/);
415
+ const hasUnsafeSegment = segments.some((seg) => !isSafeCurlWget(seg));
416
+ if (hasUnsafeSegment) {
417
+ return {
418
+ block: true,
419
+ reason: "Use context-mode MCP tools (execute, fetch_and_index) instead of inline HTTP clients. " +
420
+ "Raw curl/wget output floods the context window. " +
421
+ "For an MCP-down escape hatch, use silent + file output: " +
422
+ "`curl -s -o /tmp/x.json URL` or `wget -q -O /tmp/x.json URL`.",
423
+ };
424
+ }
425
+ }
329
426
  }
330
427
  catch {
331
428
  // Routing failure — allow passthrough
@@ -337,7 +434,12 @@ export default function piExtension(pi) {
337
434
  if (!_sessionId)
338
435
  return;
339
436
  const rawToolName = String(event?.toolName ?? event?.tool_name ?? "");
340
- const mappedToolName = PI_TOOL_MAP[rawToolName.toLowerCase()] ?? rawToolName;
437
+ let mappedToolName = PI_TOOL_MAP[rawToolName.toLowerCase()] ?? rawToolName;
438
+ // Pi namespaces MCP-registered tools with the "context_mode_" prefix;
439
+ // the extract functions expect the "mcp__" prefix for MCP tool calls.
440
+ if (/^context_mode_/.test(rawToolName)) {
441
+ mappedToolName = rawToolName.replace(/^context_mode_/, "mcp__context_mode__");
442
+ }
341
443
  // Normalize result to string
342
444
  const rawResult = event?.result ?? event?.output;
343
445
  const resultStr = typeof rawResult === "string"
@@ -347,9 +449,16 @@ export default function piExtension(pi) {
347
449
  : undefined;
348
450
  // Detect errors
349
451
  const hasError = Boolean(event?.error || event?.isError);
452
+ // Pi sends file tool parameters as "path"; the extract functions
453
+ // expect "file_path" (Claude Code convention). Normalise before
454
+ // passing to extractEvents so file reads/writes/edits are tracked.
455
+ const rawInput = { ...(event?.params ?? event?.input ?? {}) };
456
+ if (rawInput.path !== undefined && rawInput.file_path === undefined) {
457
+ rawInput.file_path = String(rawInput.path);
458
+ }
350
459
  const hookInput = {
351
460
  tool_name: mappedToolName,
352
- tool_input: event?.params ?? event?.input ?? {},
461
+ tool_input: rawInput,
353
462
  tool_response: resultStr,
354
463
  tool_output: hasError ? { isError: true } : undefined,
355
464
  };
package/build/cli.js CHANGED
@@ -58,6 +58,7 @@ const HOOK_MAP = {
58
58
  userpromptsubmit: "hooks/userpromptsubmit.mjs",
59
59
  },
60
60
  "gemini-cli": {
61
+ beforeagent: "hooks/gemini-cli/beforeagent.mjs",
61
62
  beforetool: "hooks/gemini-cli/beforetool.mjs",
62
63
  aftertool: "hooks/gemini-cli/aftertool.mjs",
63
64
  precompress: "hooks/gemini-cli/precompress.mjs",
@@ -1252,7 +1253,27 @@ async function upgrade(opts) {
1252
1253
  const message = err instanceof Error ? err.message : String(err);
1253
1254
  s.stop(color.red("Update failed"));
1254
1255
  p.log.error(color.red("GitHub pull failed") + ` — ${message}`);
1255
- p.log.info(color.dim("Continuing with hooks/settings fix..."));
1256
+ // Issue #628 — Windows `spawnSync cmd.exe ETIMEDOUT` (and any
1257
+ // other Step 1/2 throw — network, npm, manifest mismatch) used
1258
+ // to fall through to Steps 3-7 (backup, hooks, perms, doctor),
1259
+ // all of which succeed against the OLD on-disk install. The
1260
+ // process then exited 0 and the upgrade-checklist renderer
1261
+ // marked `[x] Built and installed vNEW` while in-place files,
1262
+ // installed_plugins.json registry, and per-version cache dirs
1263
+ // stayed at vOLD. Worse: the marketplace clone synced earlier
1264
+ // in this same run is now AHEAD of cache+registry — Claude
1265
+ // Code's plugin manager keeps offering the same upgrade
1266
+ // forever (drift trap; reporter had to hand-edit
1267
+ // installed_plugins.json to escape).
1268
+ //
1269
+ // Algo defense: mark the process for non-zero exit and surface
1270
+ // an actionable recovery hint. Steps 3-7 still run because the
1271
+ // user's hooks may be broken regardless — but the overall
1272
+ // upgrade no longer reports success.
1273
+ process.exitCode = 1;
1274
+ p.log.warn(color.yellow("In-place files were NOT updated") +
1275
+ color.dim(" — old version is still on disk; hooks/settings will still be refreshed."));
1276
+ p.log.info(color.dim(" Recovery: re-run /ctx-upgrade once network is stable, or run /context-mode:ctx-doctor for a full health check."));
1256
1277
  try {
1257
1278
  rmSync(tmpDir, { recursive: true, force: true });
1258
1279
  }
package/build/server.js CHANGED
@@ -1163,8 +1163,13 @@ server.registerTool("ctx_execute", {
1163
1163
  .coerce.number()
1164
1164
  .optional()
1165
1165
  .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)."),
1166
+ // background: wrapped in coerceBoolean preprocessor so the literal
1167
+ // strings "true"/"false" arriving from OpenCode's native plugin
1168
+ // bridge (and several LLM providers' tool-call JSON) parse as the
1169
+ // boolean the handler expects. z.coerce.boolean() is unsafe here —
1170
+ // Boolean("false") is true. Fixes #627.
1166
1171
  background: z
1167
- .boolean()
1172
+ .preprocess(coerceBoolean, z.boolean())
1168
1173
  .optional()
1169
1174
  .default(false)
1170
1175
  .describe("Keep process running after timeout (for servers/daemons). Returns partial output without killing the process. IMPORTANT: Do NOT add setTimeout/self-close timers in background scripts — the process must stay alive until the timeout detaches it. For server+fetch patterns, prefer putting both server and fetch in ONE ctx_execute call instead of using background."),
@@ -1654,19 +1659,57 @@ const SEARCH_WINDOW_MS = 60_000;
1654
1659
  const SEARCH_MAX_RESULTS_AFTER = 3; // after 3 calls: 1 result per query
1655
1660
  const SEARCH_BLOCK_AFTER = 8; // after 8 calls: refuse, demand batching
1656
1661
  /**
1657
- * Defensive coercion: parse stringified JSON arrays.
1658
- * Works around Claude Code double-serialization bug where array params
1659
- * are sent as JSON strings (e.g. "[\"a\",\"b\"]" instead of ["a","b"]).
1660
- * See: https://github.com/anthropics/claude-code/issues/34520
1662
+ * Defensive coercion: parse stringified JSON arrays, AND lift a bare
1663
+ * non-empty string into a single-element array.
1664
+ *
1665
+ * Two shapes show up from the wild:
1666
+ * 1. `"[\"a\",\"b\"]"` — Claude Code double-serialization bug
1667
+ * (https://github.com/anthropics/claude-code/issues/34520).
1668
+ * 2. `"single query"` — some LLM providers / OpenCode's native plugin
1669
+ * bridge deliver a single string when the schema expects `string[]`
1670
+ * (issue #627). v1.0.139 (#621) made the bridge run the Zod schema,
1671
+ * so this now surfaces as `Expected array, received string`. The
1672
+ * ergonomic recovery is to treat it as `["single query"]`.
1673
+ *
1674
+ * An empty string is intentionally NOT lifted — empty input should still
1675
+ * fail Zod's `.min(1)` check rather than masquerade as `[""]`.
1661
1676
  */
1662
1677
  function coerceJsonArray(val) {
1663
1678
  if (typeof val === "string") {
1679
+ const trimmed = val.trim();
1680
+ if (trimmed.length === 0)
1681
+ return val; // let zod produce "non-empty" error
1664
1682
  try {
1665
1683
  const parsed = JSON.parse(val);
1666
1684
  if (Array.isArray(parsed))
1667
1685
  return parsed;
1668
1686
  }
1669
- catch { /* not valid JSON, let zod handle the error */ }
1687
+ catch { /* fall through — not JSON, treat as bare-string lift */ }
1688
+ // Bare-string lift (#627): single query delivered as a plain string.
1689
+ return [val];
1690
+ }
1691
+ return val;
1692
+ }
1693
+ /**
1694
+ * Defensive coercion: accept the string literals "true"/"false" as
1695
+ * booleans. The OpenCode native plugin bridge (and several LLM providers'
1696
+ * tool-call JSON) stringifies primitives — `background:"false"` instead
1697
+ * of `background:false`, `confirm:"true"` instead of `confirm:true`.
1698
+ *
1699
+ * We deliberately do NOT use `z.coerce.boolean()` for boolean fields:
1700
+ * `Boolean("false")` is `true`, so Zod's coerce path silently flips the
1701
+ * meaning. This helper recognises only the documented literal forms and
1702
+ * passes anything else through untouched so Zod surfaces the right error.
1703
+ *
1704
+ * Fixes #627.
1705
+ */
1706
+ function coerceBoolean(val) {
1707
+ if (typeof val === "string") {
1708
+ const t = val.trim().toLowerCase();
1709
+ if (t === "true")
1710
+ return true;
1711
+ if (t === "false")
1712
+ return false;
1670
1713
  }
1671
1714
  return val;
1672
1715
  }
@@ -1693,8 +1736,16 @@ server.registerTool("ctx_search", {
1693
1736
  .array(z.string())
1694
1737
  .optional()
1695
1738
  .describe("Array of search queries. Batch ALL questions in one call.")),
1739
+ // limit: z.coerce.number() (not z.number()) — OpenCode's native
1740
+ // plugin path delivers tool args straight from the LLM provider's
1741
+ // tool-call JSON, where several providers stringify primitives
1742
+ // (limit:"4" instead of limit:4). Since v1.0.139 / #621 we run
1743
+ // inputSchema.parse() on that path, so a plain z.number() rejects
1744
+ // "4" with "Expected number, received string". z.coerce mirrors what
1745
+ // ctx_batch_execute / ctx_fetch_and_index / ctx_execute already do.
1746
+ // Fixes #627.
1696
1747
  limit: z
1697
- .number()
1748
+ .coerce.number()
1698
1749
  .optional()
1699
1750
  .default(3)
1700
1751
  .describe("Results per query (default: 3)"),
@@ -3154,7 +3205,11 @@ server.registerTool("ctx_purge", {
3154
3205
  // .superRefine() wrapper. See block comment above & issue #563. The
3155
3206
  // cross-field ambiguity check lives in the handler body below.
3156
3207
  inputSchema: z.object({
3157
- confirm: z.boolean().describe("MUST be true. Destructive operation; false returns 'purge cancelled'."),
3208
+ // confirm: wrapped in coerceBoolean preprocessor OpenCode's native
3209
+ // plugin bridge can deliver `confirm:"true"` / `confirm:"false"` as
3210
+ // string literals. Without this, v1.0.139's inputSchema.parse() path
3211
+ // rejects valid intent as "Expected boolean, received string" (#627).
3212
+ confirm: z.preprocess(coerceBoolean, z.boolean()).describe("MUST be true. Destructive operation; false returns 'purge cancelled'."),
3158
3213
  sessionId: z.string().optional().describe("UUID of a single session. Pairs with confirm:true to wipe only that " +
3159
3214
  "session's events + per-session FTS5 chunks. Sibling sessions and the " +
3160
3215
  "stats file are preserved. MUST NOT be combined with scope:'project'."),