context-mode 1.0.150 → 1.0.152

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.
Files changed (107) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/mcp.json +5 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.openclaw-plugin/openclaw.plugin.json +16 -1
  6. package/.openclaw-plugin/package.json +1 -1
  7. package/README.md +89 -3
  8. package/build/adapters/claude-code/hooks.js +2 -2
  9. package/build/adapters/claude-code/index.js +14 -13
  10. package/build/adapters/client-map.js +3 -0
  11. package/build/adapters/detect.js +13 -1
  12. package/build/adapters/gemini-cli/hooks.d.ts +10 -0
  13. package/build/adapters/gemini-cli/hooks.js +12 -2
  14. package/build/adapters/gemini-cli/index.d.ts +21 -1
  15. package/build/adapters/gemini-cli/index.js +37 -1
  16. package/build/adapters/kimi/config.d.ts +8 -0
  17. package/build/adapters/kimi/config.js +8 -0
  18. package/build/adapters/kimi/hooks.d.ts +28 -0
  19. package/build/adapters/kimi/hooks.js +34 -0
  20. package/build/adapters/kimi/index.d.ts +66 -0
  21. package/build/adapters/kimi/index.js +537 -0
  22. package/build/adapters/kimi/paths.d.ts +1 -0
  23. package/build/adapters/kimi/paths.js +12 -0
  24. package/build/adapters/kiro/hooks.js +2 -2
  25. package/build/adapters/openclaw/plugin.d.ts +14 -13
  26. package/build/adapters/openclaw/plugin.js +140 -40
  27. package/build/adapters/opencode/plugin.js +4 -3
  28. package/build/adapters/opencode/zod3tov4.js +8 -8
  29. package/build/adapters/pi/extension.js +9 -24
  30. package/build/adapters/pi/mcp-bridge.js +37 -0
  31. package/build/adapters/qwen-code/index.js +7 -7
  32. package/build/adapters/types.d.ts +39 -2
  33. package/build/adapters/types.js +55 -2
  34. package/build/adapters/vscode-copilot/index.js +13 -1
  35. package/build/cli.js +433 -25
  36. package/build/executor.js +6 -3
  37. package/build/runtime.d.ts +81 -1
  38. package/build/runtime.js +195 -9
  39. package/build/search/ctx-search-schema.d.ts +90 -0
  40. package/build/search/ctx-search-schema.js +135 -0
  41. package/build/search/unified.d.ts +12 -0
  42. package/build/search/unified.js +17 -2
  43. package/build/server.d.ts +2 -1
  44. package/build/server.js +378 -97
  45. package/build/session/analytics.d.ts +36 -13
  46. package/build/session/analytics.js +123 -26
  47. package/build/session/db.d.ts +24 -0
  48. package/build/session/db.js +41 -0
  49. package/build/session/extract.js +30 -0
  50. package/build/session/snapshot.js +24 -0
  51. package/build/store.d.ts +12 -1
  52. package/build/store.js +72 -20
  53. package/build/types.d.ts +7 -0
  54. package/build/util/project-dir.d.ts +19 -16
  55. package/build/util/project-dir.js +80 -45
  56. package/cli.bundle.mjs +371 -320
  57. package/configs/kimi/hooks.json +54 -0
  58. package/configs/pi/AGENTS.md +3 -85
  59. package/hooks/cache-heal-utils.mjs +148 -0
  60. package/hooks/core/formatters.mjs +26 -0
  61. package/hooks/core/routing.mjs +9 -1
  62. package/hooks/core/stdin.mjs +74 -3
  63. package/hooks/core/tool-naming.mjs +1 -0
  64. package/hooks/heal-partial-install.mjs +712 -0
  65. package/hooks/kimi/platform.mjs +1 -0
  66. package/hooks/kimi/posttooluse.mjs +72 -0
  67. package/hooks/kimi/precompact.mjs +80 -0
  68. package/hooks/kimi/pretooluse.mjs +42 -0
  69. package/hooks/kimi/sessionend.mjs +61 -0
  70. package/hooks/kimi/sessionstart.mjs +113 -0
  71. package/hooks/kimi/stop.mjs +61 -0
  72. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  73. package/hooks/normalize-hooks.mjs +66 -12
  74. package/hooks/routing-block.mjs +8 -2
  75. package/hooks/security.bundle.mjs +1 -1
  76. package/hooks/session-db.bundle.mjs +6 -4
  77. package/hooks/session-extract.bundle.mjs +2 -2
  78. package/hooks/session-helpers.mjs +93 -3
  79. package/hooks/session-snapshot.bundle.mjs +20 -19
  80. package/hooks/sessionstart.mjs +64 -0
  81. package/insight/server.mjs +15 -3
  82. package/openclaw.plugin.json +16 -1
  83. package/package.json +1 -1
  84. package/scripts/heal-installed-plugins.mjs +31 -10
  85. package/scripts/postinstall.mjs +10 -0
  86. package/server.bundle.mjs +206 -157
  87. package/skills/ctx-index/SKILL.md +46 -0
  88. package/skills/ctx-search/SKILL.md +35 -0
  89. package/start.mjs +84 -11
  90. package/build/cache-heal.d.ts +0 -48
  91. package/build/cache-heal.js +0 -150
  92. package/build/concurrency/runPool.d.ts +0 -36
  93. package/build/concurrency/runPool.js +0 -51
  94. package/build/openclaw/mcp-tools.d.ts +0 -54
  95. package/build/openclaw/mcp-tools.js +0 -198
  96. package/build/openclaw/workspace-router.d.ts +0 -29
  97. package/build/openclaw/workspace-router.js +0 -64
  98. package/build/openclaw-plugin.d.ts +0 -130
  99. package/build/openclaw-plugin.js +0 -626
  100. package/build/opencode-plugin.d.ts +0 -122
  101. package/build/opencode-plugin.js +0 -375
  102. package/build/pi-extension.d.ts +0 -14
  103. package/build/pi-extension.js +0 -451
  104. package/build/routing-block.d.ts +0 -8
  105. package/build/routing-block.js +0 -86
  106. package/build/tool-naming.d.ts +0 -4
  107. package/build/tool-naming.js +0 -24
@@ -0,0 +1,712 @@
1
+ /**
2
+ * heal-partial-install.mjs - self-heal a partial plugin cache install.
3
+ *
4
+ * Failure mode this addresses:
5
+ *
6
+ * The per-version plugin cache dir at
7
+ * ~/.claude/plugins/cache/<owner>/<plugin>/<version>/ holds only a
8
+ * subset of the published files. start.mjs, cli.bundle.mjs,
9
+ * server.bundle.mjs, package.json and several other entries from
10
+ * `package.json files[]` may be absent, while hooks/ and
11
+ * .claude-plugin/ tend to survive. The cache's
12
+ * .claude-plugin/plugin.json can also be a verbatim carry-forward
13
+ * from a prior version's install, with mcpServers.args[0] holding
14
+ * an absolute path under a since-deleted cache dir; MCP launch
15
+ * ENOENTs as a result.
16
+ *
17
+ * Existing defenses don't repair this:
18
+ *
19
+ * - scripts/plugin-cache-integrity.mjs (#550) exits 2 from
20
+ * start.mjs, but start.mjs is one of the missing files.
21
+ * - The #604 stale-cache-version ratchet in
22
+ * hooks/normalize-hooks.mjs runs from start.mjs's
23
+ * normalize-hooks call, same boot-time dependency.
24
+ * - /ctx-upgrade needs the cli.bundle.mjs the partial install lost.
25
+ *
26
+ * So the cache cannot self-recover, and the only available
27
+ * workaround from the user side is `rm -rf <version-dir>` plus a
28
+ * session restart.
29
+ *
30
+ * What this module does:
31
+ *
32
+ * Detect partial install via a cheap existsSync probe of a few
33
+ * launch-critical files. On a trip, re-copy the missing entries
34
+ * from the marketplace clone at
35
+ * ~/.claude/plugins/marketplaces/<owner>/ (Claude Code's canonical
36
+ * source for the per-version cache dir) and rewrite any
37
+ * carry-forward stale args[0] in plugin.json to the current
38
+ * pluginRoot. Both halves are mechanism-agnostic.
39
+ *
40
+ * Placement:
41
+ *
42
+ * Lives in hooks/ rather than scripts/ because the failure mode can
43
+ * strip scripts/ from the cache dir. hooks/ has been intact in every
44
+ * observed instance, so it's the most reliable available host.
45
+ *
46
+ * Scope:
47
+ *
48
+ * CC-only. The failure mode is specific to Claude Code's
49
+ * per-version cache layout at
50
+ * ~/.claude/plugins/cache/<owner>/<plugin>/<version>/. Other
51
+ * clients (Codex, Cursor, OpenCode, Kiro, ...) ship their own
52
+ * SessionStart wrappers under hooks/<client>/, none of which call
53
+ * this module. The two call sites that exist
54
+ * (hooks/sessionstart.mjs and start.mjs) are themselves CC-only
55
+ * entry points: sessionstart.mjs is wired only from
56
+ * hooks/hooks.json (CC's hook config), and start.mjs is invoked
57
+ * only by CC's .claude-plugin/plugin.json mcpServers entry. The
58
+ * module also enforces this at runtime: pluginRoot must match the
59
+ * CC cache layout (deriveMarketplaceClonePath returns null
60
+ * otherwise) or the function short-circuits with
61
+ * `skipped: "not-claude-code"`, before any further work runs.
62
+ *
63
+ * Contract:
64
+ *
65
+ * - Pure JS, Node built-ins only.
66
+ * - Never throws. Returns a structured result; callers log it.
67
+ * - Idempotent: the cheap probe makes the healthy case a few
68
+ * existsSync calls, not a full files[] expansion.
69
+ * - Path-traversal guarded at six layers, symmetric on both ends:
70
+ * 1. pluginRoot must match the CC cache layout, the derived
71
+ * marketplace path must exist and not equal pluginRoot.
72
+ * 2. files[] entries that resolve outside rootDir are dropped.
73
+ * 3. Directory walks use lstatSync and skip symlinks, so a
74
+ * symlink-to-outside in the marketplace tree can't be
75
+ * harvested as a regular file during manifest expansion.
76
+ * 4. After mkdirSync(dirname(to)), realpathSync(dirname(to)) is
77
+ * re-checked against realpathSync(pluginRoot), catching the
78
+ * case where a parent component of `to` is already a
79
+ * symlink-to-outside that mkdirSync followed.
80
+ * 5. Just before the destination write, the source is checked
81
+ * two ways: lstat(from).isSymbolicLink() drops leaf symlinks
82
+ * (fast path), and realpathSync(from) is required to fall
83
+ * under realpathSync(marketplaceClonePath). The realpath
84
+ * check collapses ancestor symlinks (e.g. a marketplace
85
+ * `scripts/` dir that's been swapped for a symlink to
86
+ * outside), which the leaf-lstat would miss because the
87
+ * leaf itself is a regular file at the symlink's resolved
88
+ * target.
89
+ * 6. Just before the destination write, lstat(to) and unlink
90
+ * any pre-existing symlink at `to`. The write itself uses
91
+ * writeFileSync with `flag: "wx"` (O_CREAT | O_EXCL), which
92
+ * per POSIX open(2) refuses to follow a symlink at the
93
+ * final component. That closes the residual race window
94
+ * where a same-user attacker re-plants a symlink at `to`
95
+ * between our unlink and the open.
96
+ * - Source-file reads that drive rewrites are symlink-checked too.
97
+ * rewritePluginJsonArgs opens `.claude-plugin/plugin.json` with
98
+ * O_NOFOLLOW, so the open(2) call itself fails with ELOOP when
99
+ * the path is a symlink. Reading from the returned fd then binds
100
+ * subsequent reads to the inode, closing the TOCTOU window that
101
+ * a lstat+readFileSync(path) pair would have left open. Without
102
+ * this, a same-user-planted redirect would feed attacker JSON to
103
+ * the read, and the subsequent atomic rename would replace the
104
+ * symlink with a regular file containing attacker-controlled
105
+ * mcpServers config.
106
+ * - plugin.json rewrites are atomic AND symlink-safe:
107
+ * writeFileSync targets a tmp sibling with a 64-bit random
108
+ * suffix (unguessable) under O_CREAT | O_EXCL (refuses to follow
109
+ * a pre-planted symlink at the tmp path), then renameSync over
110
+ * the real path. A racing writer can't observe a torn JSON file,
111
+ * and a local attacker can't redirect the write via symlink.
112
+ */
113
+
114
+ import {
115
+ existsSync,
116
+ readFileSync,
117
+ readdirSync,
118
+ lstatSync,
119
+ mkdirSync,
120
+ renameSync,
121
+ realpathSync,
122
+ unlinkSync,
123
+ writeFileSync,
124
+ appendFileSync,
125
+ openSync,
126
+ closeSync,
127
+ constants as fsConstants,
128
+ } from "node:fs";
129
+ import { join, dirname, resolve, sep } from "node:path";
130
+ import { homedir } from "node:os";
131
+ import { randomBytes } from "node:crypto";
132
+
133
+ /**
134
+ * Cheap-probe partial-install detection. Runs on every session start, so
135
+ * the healthy case must be O(few existsSync), not O(files[].length).
136
+ *
137
+ * A file is "launch-critical" when its absence keeps either start.mjs
138
+ * from booting or /ctx-upgrade from running. We don't probe every file
139
+ * in files[]; that's what the full heal does once this probe trips.
140
+ */
141
+ export function isPartialInstall(pluginRoot) {
142
+ if (!pluginRoot) return false;
143
+ if (!existsSync(join(pluginRoot, "start.mjs"))) return true;
144
+ if (!existsSync(join(pluginRoot, "package.json"))) return true;
145
+ if (
146
+ !existsSync(join(pluginRoot, "cli.bundle.mjs")) &&
147
+ !existsSync(join(pluginRoot, "build", "cli.js"))
148
+ ) {
149
+ return true;
150
+ }
151
+ if (
152
+ !existsSync(join(pluginRoot, "server.bundle.mjs")) &&
153
+ !existsSync(join(pluginRoot, "build", "server.js"))
154
+ ) {
155
+ return true;
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * Derive the marketplace clone path for a CC plugin cache pluginRoot.
162
+ *
163
+ * Layout (forward slashes on POSIX, backslashes on Windows):
164
+ * <configDir>/plugins/cache/<owner>/<plugin>/<version>/
165
+ * <configDir>/plugins/marketplaces/<owner>/
166
+ *
167
+ * Returns null when pluginRoot doesn't match the cache layout (npm-global
168
+ * install, dev checkout, opencode cache, ...). Those don't have a
169
+ * marketplace clone to heal from, and we'd rather skip than guess.
170
+ */
171
+ export function deriveMarketplaceClonePath(pluginRoot) {
172
+ if (!pluginRoot) return null;
173
+ const fwd = String(pluginRoot).replace(/\\/g, "/");
174
+ const trailing = fwd.endsWith("/") ? fwd : fwd + "/";
175
+ const m = /^(.*\/plugins\/)cache\/([^/]+)\/[^/]+\/[^/]+\/$/.exec(trailing);
176
+ if (!m) return null;
177
+ return resolve(m[1], "marketplaces", m[2]);
178
+ }
179
+
180
+ /**
181
+ * Walk a directory recursively, returning relative file paths from
182
+ * baseAbs. Skips unreadable entries silently. Uses lstatSync and skips
183
+ * symlinks so a stray symlink-to-outside in the marketplace tree can't
184
+ * be harvested as a regular file and copied into pluginRoot. statSync
185
+ * would dereference and silently follow the link.
186
+ */
187
+ function listFilesRecursive(absDir, baseAbs) {
188
+ const out = [];
189
+ let entries;
190
+ try {
191
+ entries = readdirSync(absDir);
192
+ } catch {
193
+ return out;
194
+ }
195
+ for (const name of entries) {
196
+ const full = join(absDir, name);
197
+ let st;
198
+ try {
199
+ st = lstatSync(full);
200
+ } catch {
201
+ continue;
202
+ }
203
+ if (st.isSymbolicLink()) continue;
204
+ if (st.isDirectory()) {
205
+ out.push(...listFilesRecursive(full, baseAbs));
206
+ } else if (st.isFile()) {
207
+ out.push(full.slice(baseAbs.length + 1));
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * Expand a `files[]` array against rootDir into a flat list of file paths
215
+ * (relative to rootDir, OS-native separator). Entries that don't exist on
216
+ * disk are silently dropped, matching the semantics of
217
+ * scripts/plugin-cache-integrity.mjs::derivePluginManifest.
218
+ *
219
+ * Containment: entries whose resolved path escapes rootDir (e.g. a
220
+ * corrupted marketplace package.json with `files: ["../outside.txt"]`)
221
+ * are rejected, so the downstream copy loop can't be tricked into
222
+ * reading from or writing outside the trusted root. `path.join` itself
223
+ * does not clamp `..` segments; it normalizes them. As such, we resolve
224
+ * and prefix-match against rootDir + sep explicitly.
225
+ *
226
+ * Symlinks: lstatSync + isSymbolicLink() drops top-level entries that
227
+ * are themselves symlinks. A symlink-to-outside sitting in rootDir
228
+ * passes the lexical resolve+startsWith check, since the symlink's own
229
+ * path stays inside rootDir; we have to refuse symlinks outright to
230
+ * close that bypass.
231
+ */
232
+ function expandFilesArray(rootDir, files) {
233
+ if (!Array.isArray(files)) return [];
234
+ const out = new Set();
235
+ const rootWithSep = resolve(rootDir) + sep;
236
+ for (const entry of files) {
237
+ if (typeof entry !== "string" || !entry) continue;
238
+ const abs = join(rootDir, entry);
239
+ if (!resolve(abs).startsWith(rootWithSep)) continue;
240
+ if (!existsSync(abs)) continue;
241
+ let st;
242
+ try {
243
+ st = lstatSync(abs);
244
+ } catch {
245
+ continue;
246
+ }
247
+ if (st.isSymbolicLink()) continue;
248
+ if (st.isDirectory()) {
249
+ for (const f of listFilesRecursive(abs, rootDir)) out.add(f);
250
+ } else if (st.isFile()) {
251
+ out.add(entry);
252
+ }
253
+ }
254
+ return [...out];
255
+ }
256
+
257
+ function readJsonSafe(path) {
258
+ try {
259
+ return JSON.parse(readFileSync(path, "utf-8"));
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Repair `.claude-plugin/plugin.json` mcpServers.args[0] to point at the
267
+ * current pluginRoot. This is the carry-forward fingerprint from CC's
268
+ * native plugin manager: when it creates a new version dir, it preserves
269
+ * the previous version's plugin.json (including any absolute start.mjs
270
+ * path that hooks/normalize-hooks.mjs (#604) wrote on Linux/Windows) and
271
+ * only bumps the `version` field. The result is args[0] pointing at a
272
+ * version dir that's typically been cleaned up by the age-gated sweep,
273
+ * so MCP launch ENOENTs.
274
+ *
275
+ * We can't lean on normalize-hooks.mjs's existing rewrite path since
276
+ * that fires from start.mjs at boot, and start.mjs is what's missing
277
+ * (or, if present, would have already loaded plugin.json with the stale
278
+ * path before normalize ran). The heal does the rewrite up front.
279
+ *
280
+ * Mirrors the rewrite logic in hooks/normalize-hooks.mjs for placeholder
281
+ * and stale-cache-version drift shapes. The `command` field is left
282
+ * alone (start.mjs's normalize call repairs it on the next clean boot).
283
+ */
284
+ function rewritePluginJsonArgs(pluginRoot) {
285
+ const pluginJsonPath = join(pluginRoot, ".claude-plugin", "plugin.json");
286
+ if (!existsSync(pluginJsonPath)) return false;
287
+ // Refuse to operate on plugin.json when it's a symlink, atomically.
288
+ // O_NOFOLLOW makes open(2) fail with ELOOP when the final path
289
+ // component is a symlink, in the same syscall that opens the fd.
290
+ // A naive `lstatSync().isSymbolicLink() ? return false : readFileSync(path)`
291
+ // would have a TOCTOU window between the lstat and the read where
292
+ // a same-user attacker could swap the regular file for a symlink
293
+ // pointing at attacker JSON, feeding the read attacker bytes; the
294
+ // subsequent atomic rename would then replace the symlink with a
295
+ // regular file holding attacker mcpServers config, executed at next
296
+ // MCP launch. Reading from the fd returned by openSync(O_NOFOLLOW)
297
+ // closes that window since the fd is bound to an inode, not a path.
298
+ // POSIX 0700 on ~/.claude scopes this to same-user threats; the
299
+ // defense mirrors the source/destination symlink refusals elsewhere.
300
+ let fd;
301
+ try {
302
+ fd = openSync(
303
+ pluginJsonPath,
304
+ fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW,
305
+ );
306
+ } catch {
307
+ return false;
308
+ }
309
+ let content;
310
+ try {
311
+ content = readFileSync(fd, "utf-8");
312
+ } catch {
313
+ try {
314
+ closeSync(fd);
315
+ } catch {
316
+ /* ignore */
317
+ }
318
+ return false;
319
+ }
320
+ try {
321
+ closeSync(fd);
322
+ } catch {
323
+ /* ignore */
324
+ }
325
+ let parsed;
326
+ try {
327
+ parsed = JSON.parse(content);
328
+ } catch {
329
+ return false;
330
+ }
331
+ const servers = parsed?.mcpServers;
332
+ if (!servers || typeof servers !== "object") return false;
333
+
334
+ const safeRoot = String(pluginRoot).replace(/\\/g, "/");
335
+ const versionM = /context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?:\/|$)/.exec(safeRoot);
336
+ const currentVersion = versionM ? versionM[1] : null;
337
+ const STALE_VERSION_RE = /context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?=\/)/g;
338
+ const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
339
+
340
+ let mutated = false;
341
+ for (const key of Object.keys(servers)) {
342
+ const srv = servers[key];
343
+ if (!srv || typeof srv !== "object" || !Array.isArray(srv.args)) continue;
344
+ const before = srv.args;
345
+ const after = before.map((a) => {
346
+ if (typeof a !== "string") return a;
347
+ let next = a;
348
+ if (next.includes(PLACEHOLDER)) {
349
+ next = next.replaceAll(PLACEHOLDER, safeRoot);
350
+ }
351
+ if (currentVersion) {
352
+ const fwd = next.replace(/\\/g, "/");
353
+ STALE_VERSION_RE.lastIndex = 0;
354
+ let hasStale = false;
355
+ let m;
356
+ while ((m = STALE_VERSION_RE.exec(fwd)) !== null) {
357
+ if (m[1] !== currentVersion) {
358
+ hasStale = true;
359
+ break;
360
+ }
361
+ }
362
+ if (hasStale) {
363
+ next = fwd.replace(
364
+ STALE_VERSION_RE,
365
+ `context-mode/context-mode/${currentVersion}`,
366
+ );
367
+ }
368
+ }
369
+ return next;
370
+ });
371
+ if (after.some((v, i) => v !== before[i])) {
372
+ srv.args = after;
373
+ mutated = true;
374
+ }
375
+ }
376
+
377
+ if (!mutated) return false;
378
+ // Atomic write with two security properties on top of atomicity:
379
+ // 1. Unguessable tmp filename via randomBytes(8). A local attacker
380
+ // polling /proc/<pid>/comm can predict process.pid and pre-plant
381
+ // a symlink at a deterministic tmp path, redirecting our write.
382
+ // 64 bits of randomness make that infeasible.
383
+ // 2. O_CREAT | O_EXCL via `flag: "wx"`. open(2) with O_EXCL refuses
384
+ // to follow symlinks at the final path component, raising EEXIST
385
+ // instead. So even if the random tmp name happens to collide
386
+ // with a pre-existing symlink, the write fails closed rather
387
+ // than redirecting.
388
+ // renameSync remains atomic on POSIX so a concurrent reader can't
389
+ // observe a torn JSON file. On Windows the implementation maps to
390
+ // MoveFileEx(MOVEFILE_REPLACE_EXISTING), which is atomic for
391
+ // non-directory writes.
392
+ const tmp = `${pluginJsonPath}.tmp-${randomBytes(8).toString("hex")}`;
393
+ try {
394
+ writeFileSync(tmp, JSON.stringify(parsed, null, 2), {
395
+ encoding: "utf-8",
396
+ flag: "wx",
397
+ });
398
+ renameSync(tmp, pluginJsonPath);
399
+ return true;
400
+ } catch {
401
+ // Best-effort cleanup. If writeFileSync threw, tmp may not exist;
402
+ // if renameSync threw, tmp still does. unlinkSync's own catch
403
+ // swallows ENOENT either way.
404
+ try {
405
+ unlinkSync(tmp);
406
+ } catch {
407
+ /* ignore */
408
+ }
409
+ return false;
410
+ }
411
+ }
412
+
413
+ function resolveConfigDir() {
414
+ const envVal = process.env.CLAUDE_CONFIG_DIR;
415
+ if (envVal && envVal.trim() !== "") {
416
+ if (envVal.startsWith("~")) {
417
+ return resolve(homedir(), envVal.replace(/^~[/\\]?/, ""));
418
+ }
419
+ return resolve(envVal);
420
+ }
421
+ return resolve(homedir(), ".claude");
422
+ }
423
+
424
+ function logHealResult(result) {
425
+ try {
426
+ const logDir = join(resolveConfigDir(), "context-mode");
427
+ mkdirSync(logDir, { recursive: true });
428
+ appendFileSync(
429
+ join(logDir, "heal-partial-install.log"),
430
+ JSON.stringify({ ts: new Date().toISOString(), ...result }) + "\n",
431
+ "utf-8",
432
+ );
433
+ } catch {
434
+ /* best effort */
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Heal a partial install by copying missing files from the marketplace
440
+ * clone. Best-effort and idempotent. Never throws.
441
+ *
442
+ * Inputs:
443
+ * pluginRoot - absolute path to the cache version dir (CLAUDE_PLUGIN_ROOT).
444
+ * marketplaceClonePath - absolute path to the marketplace clone. Auto-derived
445
+ * from pluginRoot when omitted.
446
+ * log - when true (default), appends a JSON line to
447
+ * ~/.claude/context-mode/heal-partial-install.log
448
+ * on every run, including skipped ones, so an
449
+ * operator can grep for evidence the hook fired.
450
+ *
451
+ * Returns an object whose exact shape depends on the branch taken.
452
+ * Common fields:
453
+ * healed: string[] // relative paths successfully copied (always present, [] when skipped)
454
+ * stillMissing: string[] // relative paths the heal couldn't restore (always present, [] when skipped)
455
+ * skipped?: string // reason the heal short-circuited; absent on the success path
456
+ * pluginRoot?: string // echoed back when known; absent only on the "no-plugin-root" branch
457
+ * pkgSource?: string // "marketplace" or "pluginRoot", which package.json the files[] came from; absent until the manifest read happens
458
+ * argsRewritten?: boolean // whether plugin.json mcpServers.args was rewritten; present on the success path and on "files-already-present"
459
+ * missingBefore?: string[] // present on the success path only; relative paths that were missing before the copy loop ran
460
+ *
461
+ * Branch matrix:
462
+ * skipped="no-plugin-root" : {healed, stillMissing, skipped}
463
+ * skipped="not-claude-code" : {healed, stillMissing, skipped, pluginRoot}
464
+ * skipped="not-partial" : {healed, stillMissing, skipped, pluginRoot}
465
+ * skipped="no-marketplace" : {healed, stillMissing, skipped, pluginRoot}
466
+ * skipped="same-as-marketplace" : {healed, stillMissing, skipped, pluginRoot}
467
+ * skipped="no-files-manifest" : {healed, stillMissing, skipped, pluginRoot, pkgSource}
468
+ * skipped="marketplace-empty" : {healed, stillMissing, skipped, pluginRoot, pkgSource}
469
+ * skipped="files-already-present" : {healed, stillMissing, skipped, pluginRoot, pkgSource, argsRewritten}
470
+ * success path (no `skipped` key) : {healed, stillMissing, pluginRoot, pkgSource, argsRewritten, missingBefore}
471
+ */
472
+ export function healPartialInstallFromMarketplace(opts = {}) {
473
+ const pluginRoot = opts.pluginRoot ?? process.env.CLAUDE_PLUGIN_ROOT;
474
+ const log = opts.log !== false;
475
+
476
+ if (!pluginRoot) {
477
+ const result = {
478
+ healed: [],
479
+ stillMissing: [],
480
+ skipped: "no-plugin-root",
481
+ };
482
+ if (log) logHealResult(result);
483
+ return result;
484
+ }
485
+
486
+ // CC-only scope check. The partial-install failure mode this module
487
+ // addresses is specific to Claude Code's per-version cache layout at
488
+ // ~/.claude/plugins/cache/<owner>/<plugin>/<version>/. Other clients
489
+ // (Codex, Cursor, OpenCode, Kiro, ...) ship their own SessionStart
490
+ // hooks under hooks/<client>/ and don't go through this module at
491
+ // all; npm-global, npx, and dev-checkout installs don't have the
492
+ // cache layout either. deriveMarketplaceClonePath returns null for
493
+ // anything that isn't a CC cache pluginRoot. Bailing here keeps the
494
+ // healthy-case fast path cheap for non-CC contexts (no isPartialInstall
495
+ // probe, no filesystem reads) and makes the scope intent explicit in
496
+ // the log: a "not-claude-code" line is the signal that the heal saw
497
+ // a non-CC pluginRoot.
498
+ const marketplaceClonePath =
499
+ opts.marketplaceClonePath ?? deriveMarketplaceClonePath(pluginRoot);
500
+ if (!marketplaceClonePath) {
501
+ const result = {
502
+ healed: [],
503
+ stillMissing: [],
504
+ skipped: "not-claude-code",
505
+ pluginRoot,
506
+ };
507
+ if (log) logHealResult(result);
508
+ return result;
509
+ }
510
+
511
+ if (!isPartialInstall(pluginRoot)) {
512
+ const result = {
513
+ healed: [],
514
+ stillMissing: [],
515
+ skipped: "not-partial",
516
+ pluginRoot,
517
+ };
518
+ if (log) logHealResult(result);
519
+ return result;
520
+ }
521
+
522
+ if (!existsSync(marketplaceClonePath)) {
523
+ const result = {
524
+ healed: [],
525
+ stillMissing: [],
526
+ skipped: "no-marketplace",
527
+ pluginRoot,
528
+ };
529
+ if (log) logHealResult(result);
530
+ return result;
531
+ }
532
+
533
+ // Path-traversal guard: refuse to heal a pluginRoot that is itself the
534
+ // marketplace clone (would happen on a dev checkout where someone
535
+ // symlinks the cache into the marketplace tree).
536
+ if (resolve(pluginRoot) === resolve(marketplaceClonePath)) {
537
+ const result = {
538
+ healed: [],
539
+ stillMissing: [],
540
+ skipped: "same-as-marketplace",
541
+ pluginRoot,
542
+ };
543
+ if (log) logHealResult(result);
544
+ return result;
545
+ }
546
+
547
+ // Prefer the marketplace clone's package.json. The heal only runs
548
+ // once we've established that pluginRoot is in a partial state, and
549
+ // its package.json (when present at all) can itself be a stale
550
+ // carry-forward from a prior version. The marketplace clone is the
551
+ // canonical source CC extracted the cache from, so its files[] is
552
+ // the right manifest to expand. Fall back to pluginRoot only when
553
+ // the marketplace clone's package.json is unreadable.
554
+ let pkg = readJsonSafe(join(marketplaceClonePath, "package.json"));
555
+ let pkgSource = "marketplace";
556
+ if (!pkg) {
557
+ pkg = readJsonSafe(join(pluginRoot, "package.json"));
558
+ pkgSource = "pluginRoot";
559
+ }
560
+ if (!pkg || !Array.isArray(pkg.files)) {
561
+ const result = {
562
+ healed: [],
563
+ stillMissing: [],
564
+ skipped: "no-files-manifest",
565
+ pluginRoot,
566
+ pkgSource,
567
+ };
568
+ if (log) logHealResult(result);
569
+ return result;
570
+ }
571
+
572
+ // Always include package.json itself so the next boot's getLocalVersion
573
+ // and integrity check have something to read. npm's `files[]` semantics
574
+ // include package.json implicitly; we have to add it back manually since
575
+ // we're expanding the array ourselves.
576
+ const items = new Set(pkg.files);
577
+ items.add("package.json");
578
+
579
+ // Expand against the marketplace clone since that's the source of truth.
580
+ // Entries that don't exist on the marketplace side are dropped.
581
+ const expanded = expandFilesArray(marketplaceClonePath, [...items]);
582
+ if (expanded.length === 0) {
583
+ const result = {
584
+ healed: [],
585
+ stillMissing: [],
586
+ skipped: "marketplace-empty",
587
+ pluginRoot,
588
+ pkgSource,
589
+ };
590
+ if (log) logHealResult(result);
591
+ return result;
592
+ }
593
+
594
+ const missingBefore = expanded.filter((rel) => !existsSync(join(pluginRoot, rel)));
595
+ if (missingBefore.length === 0) {
596
+ // The cheap probe tripped but the full expansion shows everything's
597
+ // present, e.g. start.mjs is missing but isn't in files[] anymore
598
+ // (unlikely, but defensive). Still attempt the args rewrite in case
599
+ // a carry-forward plugin.json drifted independently.
600
+ const argsRewritten = rewritePluginJsonArgs(pluginRoot);
601
+ const result = {
602
+ healed: [],
603
+ stillMissing: [],
604
+ argsRewritten,
605
+ pkgSource,
606
+ pluginRoot,
607
+ skipped: "files-already-present",
608
+ };
609
+ if (log) logHealResult(result);
610
+ return result;
611
+ }
612
+
613
+ // Copy each missing item. The guards below mirror the Contract
614
+ // block at the top of this module: layer 2 (lexical), layer 4
615
+ // (realpath on dest), layer 5 (lstat + realpath on source), layer 6
616
+ // (lstat + unlink on dest, plus an O_EXCL destination open). The
617
+ // non-layered existsSync(from) guard skips entries that disappeared
618
+ // from the marketplace between manifest expansion and this
619
+ // iteration, so the read isn't asked to open a missing source.
620
+ // realpathSync(pluginRoot) and realpathSync(marketplaceClonePath)
621
+ // are cached once outside the loop; failures there just disable
622
+ // the realpath guards for this run, leaving the lexical guards in
623
+ // force (heal contract: never throws).
624
+ const pluginRootWithSep = resolve(pluginRoot) + sep;
625
+ const marketplaceWithSep = resolve(marketplaceClonePath) + sep;
626
+ let pluginRootRealWithSep = null;
627
+ try {
628
+ pluginRootRealWithSep = realpathSync(pluginRoot) + sep;
629
+ } catch {
630
+ /* lexical guard still in force */
631
+ }
632
+ let marketplaceCloneRealWithSep = null;
633
+ try {
634
+ marketplaceCloneRealWithSep = realpathSync(marketplaceClonePath) + sep;
635
+ } catch {
636
+ /* lexical guard still in force */
637
+ }
638
+ const healed = [];
639
+ for (const rel of missingBefore) {
640
+ const from = join(marketplaceClonePath, rel);
641
+ const to = join(pluginRoot, rel);
642
+ if (!resolve(from).startsWith(marketplaceWithSep)) continue;
643
+ if (!resolve(to).startsWith(pluginRootWithSep)) continue;
644
+ if (!existsSync(from)) continue;
645
+ try {
646
+ mkdirSync(dirname(to), { recursive: true });
647
+ if (pluginRootRealWithSep) {
648
+ const toParentReal = realpathSync(dirname(to)) + sep;
649
+ if (!toParentReal.startsWith(pluginRootRealWithSep)) continue;
650
+ }
651
+ let stFrom;
652
+ try {
653
+ stFrom = lstatSync(from);
654
+ } catch {
655
+ continue;
656
+ }
657
+ if (stFrom.isSymbolicLink()) continue;
658
+ if (marketplaceCloneRealWithSep) {
659
+ let fromReal;
660
+ try {
661
+ fromReal = realpathSync(from);
662
+ } catch {
663
+ continue;
664
+ }
665
+ if (!(fromReal + sep).startsWith(marketplaceCloneRealWithSep)) {
666
+ continue;
667
+ }
668
+ }
669
+ try {
670
+ const stTo = lstatSync(to);
671
+ if (stTo.isSymbolicLink()) unlinkSync(to);
672
+ } catch {
673
+ /* `to` doesn't exist yet, which is the common case */
674
+ }
675
+ // Read-then-write with O_CREAT | O_EXCL (Node's `wx` flag) so
676
+ // the destination open refuses to follow a symlink at the leaf.
677
+ // This closes the residual race window between the unlinkSync
678
+ // above and the destination open: a same-user attacker who
679
+ // re-plants a symlink at `to` after our unlink would have made
680
+ // a default cpSync (or any non-EXCL open) follow it. EXCL on
681
+ // its own implies "do not follow symlinks at the final
682
+ // component" per POSIX open(2), so we don't need an explicit
683
+ // O_NOFOLLOW. mode is preserved from the source so executable
684
+ // bits on bin/* survive the copy.
685
+ const content = readFileSync(from);
686
+ writeFileSync(to, content, {
687
+ flag: "wx",
688
+ mode: stFrom.mode & 0o777,
689
+ });
690
+ healed.push(rel);
691
+ } catch {
692
+ /* best-effort: keep going so as many files as possible get restored */
693
+ }
694
+ }
695
+
696
+ const argsRewritten = rewritePluginJsonArgs(pluginRoot);
697
+ const stillMissing = expanded.filter((rel) => !existsSync(join(pluginRoot, rel)));
698
+
699
+ const result = {
700
+ healed,
701
+ stillMissing,
702
+ argsRewritten,
703
+ pkgSource,
704
+ missingBefore,
705
+ pluginRoot,
706
+ };
707
+ if (log) logHealResult(result);
708
+ return result;
709
+ }
710
+
711
+ // Default export for ergonomic call sites that don't need the named exports.
712
+ export default healPartialInstallFromMarketplace;