@sentropic/h2a 0.90.0 → 0.90.2

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 (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/dist/bin.js +17 -0
  4. package/dist/bin.js.map +1 -1
  5. package/dist/cli-contract.js +2 -2
  6. package/dist/cli-contract.js.map +1 -1
  7. package/dist/cli.d.ts +6 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +68 -7
  10. package/dist/cli.js.map +1 -1
  11. package/dist/hosts/installation-doctor.d.ts +119 -0
  12. package/dist/hosts/installation-doctor.d.ts.map +1 -0
  13. package/dist/hosts/installation-doctor.js +2093 -0
  14. package/dist/hosts/installation-doctor.js.map +1 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/runtime/feed/index.d.ts +1 -0
  20. package/dist/runtime/feed/index.d.ts.map +1 -1
  21. package/dist/runtime/feed/index.js +1 -0
  22. package/dist/runtime/feed/index.js.map +1 -1
  23. package/dist/runtime/feed/local.d.ts +43 -0
  24. package/dist/runtime/feed/local.d.ts.map +1 -0
  25. package/dist/runtime/feed/local.js +31 -0
  26. package/dist/runtime/feed/local.js.map +1 -0
  27. package/dist/runtime/host-config-root.d.ts +15 -0
  28. package/dist/runtime/host-config-root.d.ts.map +1 -0
  29. package/dist/runtime/host-config-root.js +26 -0
  30. package/dist/runtime/host-config-root.js.map +1 -0
  31. package/dist/runtime/identity/readers.d.ts.map +1 -1
  32. package/dist/runtime/identity/readers.js +4 -3
  33. package/dist/runtime/identity/readers.js.map +1 -1
  34. package/dist/runtime/local-files/paths.d.ts.map +1 -1
  35. package/dist/runtime/local-files/paths.js +10 -1
  36. package/dist/runtime/local-files/paths.js.map +1 -1
  37. package/dist/runtime/mcp/handlers.d.ts.map +1 -1
  38. package/dist/runtime/mcp/handlers.js +2 -1
  39. package/dist/runtime/mcp/handlers.js.map +1 -1
  40. package/dist/runtime/version/agent-version.d.ts.map +1 -1
  41. package/dist/runtime/version/agent-version.js +3 -2
  42. package/dist/runtime/version/agent-version.js.map +1 -1
  43. package/dist/status-bar-writer.d.ts +39 -0
  44. package/dist/status-bar-writer.d.ts.map +1 -0
  45. package/dist/status-bar-writer.js +249 -0
  46. package/dist/status-bar-writer.js.map +1 -0
  47. package/package.json +2 -2
@@ -0,0 +1,2093 @@
1
+ /**
2
+ * Host-installation convergence for `h2a doctor`.
3
+ *
4
+ * The h2a plugin is the canonical host integration. A direct `mcp-serve`
5
+ * configuration alongside that plugin starts a second endpoint, and the old
6
+ * standalone Track MCP starts a competing projection of `track_*` tools. This
7
+ * module treats both as an incoherent installation rather than a warning.
8
+ *
9
+ * Host plugin CLIs retain authority over their own installation registries.
10
+ * Doctor repairs their plain configuration/cache residue itself, then asks the
11
+ * native CLI to install/update the canonical `h2a@sentropic` plugin. A command
12
+ * failure is retained as an unrepaired finding; it must never become a clean
13
+ * result merely because a config file was edited.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { closeSync, existsSync, fsyncSync, lstatSync, mkdtempSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { homedir, tmpdir } from "node:os";
18
+ import { delimiter, dirname, join } from "node:path";
19
+ import { resolveHostConfigCompanionBase, resolveHostConfigRoot } from "../runtime/host-config-root.js";
20
+ import { currentCliVersion } from "../runtime/upgrade/index.js";
21
+ export const H2A_PLUGIN_SELECTOR = "h2a@sentropic";
22
+ export const H2A_MARKETPLACE_NAME = "sentropic";
23
+ export const H2A_MARKETPLACE_REPOSITORY = "rhanka/h2a";
24
+ export const H2A_MARKETPLACE_GIT_URL = "https://github.com/rhanka/h2a.git";
25
+ export const HOST_REPAIR_FRESHNESS_GUARANTEE = "doctor guarantees the coherence of the repairs it performed. It does not detect installation changes made by other tools; after changing your installation by hand, restart your sessions.";
26
+ export const HOST_REPAIR_NATIVE_COMMAND_FAILURE_LIMIT = "If a native host CLI fails after it has already changed the installation, doctor reports the failure as host-command-failed and does not undo what that CLI already did. Doctor's own configuration writes are atomic. It has no snapshot of third-party state and does not simulate one: a partial restore would promise a recovery it cannot deliver. After a reported native failure, verify the host installation before relying on it.";
27
+ function defaultHostCommand(command, args, context) {
28
+ try {
29
+ const result = spawnSync(command, [...args], {
30
+ encoding: "utf8",
31
+ timeout: 120_000,
32
+ ...(context?.cwd ? { cwd: context.cwd } : {}),
33
+ ...(context?.env ? { env: context.env } : {})
34
+ });
35
+ if (result.status === 0)
36
+ return { ok: true };
37
+ const detail = [result.stderr, result.stdout, result.error?.message]
38
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
39
+ .join(" ")
40
+ .trim();
41
+ return {
42
+ ok: false,
43
+ message: detail || `${command} ${args.join(" ")} exited ${result.status ?? "without a status"}`
44
+ };
45
+ }
46
+ catch (error) {
47
+ return { ok: false, message: error.message };
48
+ }
49
+ }
50
+ function finding(code, message, path) {
51
+ return { code, message, ...(path ? { path } : {}) };
52
+ }
53
+ function isBlockingFinding(entry) {
54
+ return entry.code !== "config-preserved" && entry.code !== "orphan-cache" && entry.code !== "host-not-installed";
55
+ }
56
+ function hostFindingBucket(entry) {
57
+ if (["config-preserved", "orphan-cache", "host-not-installed"].includes(entry.code))
58
+ return "preserved";
59
+ if (["host-cli-unavailable", "host-config-unavailable", "host-config-unverifiable", "ownership-unverified", "repair-marker-unavailable", "runtime-artifact-unavailable"].includes(entry.code)) {
60
+ return "unverifiable";
61
+ }
62
+ return "failure";
63
+ }
64
+ function uniqueFindings(entries) {
65
+ const seen = new Set();
66
+ return entries.filter((entry) => {
67
+ const key = `${entry.code}\u0000${entry.path ?? ""}\u0000${entry.message}`;
68
+ if (seen.has(key))
69
+ return false;
70
+ seen.add(key);
71
+ return true;
72
+ });
73
+ }
74
+ function isPlainObject(value) {
75
+ return typeof value === "object" && value !== null && !Array.isArray(value);
76
+ }
77
+ function readJson(path) {
78
+ if (!existsSync(path))
79
+ return {};
80
+ try {
81
+ const value = JSON.parse(readFileSync(path, "utf8"));
82
+ if (!isPlainObject(value))
83
+ return { error: "JSON root is not an object" };
84
+ return { value };
85
+ }
86
+ catch (error) {
87
+ return { error: error.message };
88
+ }
89
+ }
90
+ function writeJson(path, value) {
91
+ try {
92
+ mkdirSync(dirname(path), { recursive: true });
93
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
94
+ return undefined;
95
+ }
96
+ catch (error) {
97
+ return error.message;
98
+ }
99
+ }
100
+ function validateRenderedToml(raw) {
101
+ const headers = new Set();
102
+ const delimiters = [];
103
+ let quote;
104
+ let escaped = false;
105
+ const scanValue = (value, line) => {
106
+ let hasValue = quote !== undefined || delimiters.length > 0;
107
+ for (const character of value) {
108
+ if (quote) {
109
+ hasValue = true;
110
+ if (quote === '"' && escaped) {
111
+ escaped = false;
112
+ continue;
113
+ }
114
+ if (quote === '"' && character === "\\") {
115
+ escaped = true;
116
+ continue;
117
+ }
118
+ if (character === quote)
119
+ quote = undefined;
120
+ continue;
121
+ }
122
+ if (character === "#")
123
+ break;
124
+ if (!/\s/.test(character))
125
+ hasValue = true;
126
+ if (character === '"' || character === "'")
127
+ quote = character;
128
+ else if (character === "[")
129
+ delimiters.push("]");
130
+ else if (character === "{")
131
+ delimiters.push("}");
132
+ else if (character === "]" || character === "}") {
133
+ if (delimiters.pop() !== character)
134
+ return `line ${line} has unbalanced TOML delimiters`;
135
+ }
136
+ }
137
+ return hasValue ? undefined : `line ${line} has no TOML value`;
138
+ };
139
+ for (const [index, line] of raw.split(/\r?\n/).entries()) {
140
+ const trimmed = line.trim();
141
+ if (quote || delimiters.length > 0) {
142
+ const valueError = scanValue(line, index + 1);
143
+ if (valueError)
144
+ return valueError;
145
+ continue;
146
+ }
147
+ if (trimmed.length === 0 || trimmed.startsWith("#"))
148
+ continue;
149
+ if (trimmed.startsWith("[")) {
150
+ const arrayHeader = /^\[\[([^\]\r\n]+)\]\]\s*(?:#.*)?$/.exec(trimmed);
151
+ if (arrayHeader)
152
+ continue;
153
+ const header = /^\[([^\]\r\n]+)\]\s*(?:#.*)?$/.exec(trimmed);
154
+ if (!header)
155
+ return `line ${index + 1} has an invalid table header`;
156
+ if (headers.has(header[1]))
157
+ return `line ${index + 1} repeats table ${header[1]}`;
158
+ headers.add(header[1]);
159
+ continue;
160
+ }
161
+ if (!/^(?:[A-Za-z0-9_-]+|"(?:\\.|[^"\\])+")(?:\.(?:[A-Za-z0-9_-]+|"(?:\\.|[^"\\])+"))*\s*=/.test(trimmed)) {
162
+ return `line ${index + 1} is not a TOML key/value assignment`;
163
+ }
164
+ const valueError = scanValue(trimmed.slice(trimmed.indexOf("=") + 1), index + 1);
165
+ if (valueError)
166
+ return valueError;
167
+ }
168
+ if (quote)
169
+ return "TOML has an unterminated string";
170
+ if (delimiters.length > 0)
171
+ return "TOML has unclosed array or inline-table delimiters";
172
+ return undefined;
173
+ }
174
+ function validateRenderedConfiguration(raw, format) {
175
+ if (format === "toml")
176
+ return validateRenderedToml(raw);
177
+ try {
178
+ if (!isPlainObject(JSON.parse(raw)))
179
+ return "JSON root is not an object";
180
+ return undefined;
181
+ }
182
+ catch (error) {
183
+ return error.message;
184
+ }
185
+ }
186
+ function atomicConfigurationWrite(path, original, rendered, format, testHooks, beforeReplace) {
187
+ let temporaryPath;
188
+ let descriptor;
189
+ try {
190
+ const candidate = testHooks?.mutateRendered?.(path, format, rendered) ?? rendered;
191
+ const validationError = validateRenderedConfiguration(candidate, format);
192
+ if (validationError)
193
+ return `rendered ${format.toUpperCase()} cannot be parsed: ${validationError}`;
194
+ mkdirSync(dirname(path), { recursive: true });
195
+ for (let attempt = 0; attempt < 10; attempt++) {
196
+ const candidatePath = join(dirname(path), `.${path.split("/").pop()}.h2a-${process.pid}-${Date.now()}-${attempt}.tmp`);
197
+ try {
198
+ descriptor = openSync(candidatePath, "wx", 0o600);
199
+ temporaryPath = candidatePath;
200
+ break;
201
+ }
202
+ catch (error) {
203
+ if (error.code !== "EEXIST")
204
+ throw error;
205
+ }
206
+ }
207
+ if (descriptor === undefined || temporaryPath === undefined)
208
+ throw new Error("cannot allocate an atomic temporary file");
209
+ writeFileSync(descriptor, candidate, "utf8");
210
+ fsyncSync(descriptor);
211
+ closeSync(descriptor);
212
+ descriptor = undefined;
213
+ testHooks?.beforeRename?.(path, temporaryPath);
214
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
215
+ if (current !== original)
216
+ throw new Error("configuration changed concurrently before atomic rename");
217
+ const replacementError = beforeReplace?.();
218
+ if (replacementError)
219
+ throw new Error(replacementError);
220
+ if (testHooks?.rename)
221
+ testHooks.rename(temporaryPath, path);
222
+ else
223
+ renameSync(temporaryPath, path);
224
+ temporaryPath = undefined;
225
+ return undefined;
226
+ }
227
+ catch (error) {
228
+ return error.message;
229
+ }
230
+ finally {
231
+ if (descriptor !== undefined) {
232
+ try {
233
+ closeSync(descriptor);
234
+ }
235
+ catch { /* preserve the original write error */ }
236
+ }
237
+ if (temporaryPath !== undefined) {
238
+ try {
239
+ unlinkSync(temporaryPath);
240
+ }
241
+ catch { /* cleanup must not hide the original write error */ }
242
+ }
243
+ }
244
+ }
245
+ function listDirectories(path) {
246
+ try {
247
+ return readdirSync(path, { withFileTypes: true })
248
+ .filter((entry) => entry.isDirectory())
249
+ .map((entry) => entry.name);
250
+ }
251
+ catch {
252
+ return [];
253
+ }
254
+ }
255
+ function loadBearingArtifacts(report, roots, hostConfigPaths) {
256
+ const declared = new Set(hostConfigPaths.filter(existsSync));
257
+ const inspectedMcpConfigs = new Set();
258
+ const inspectMcpConfig = (path) => {
259
+ if (inspectedMcpConfigs.has(path))
260
+ return;
261
+ inspectedMcpConfigs.add(path);
262
+ declared.add(path);
263
+ if (!existsSync(path))
264
+ return;
265
+ const config = readJson(path);
266
+ if (config.error) {
267
+ report.findings.push(finding("runtime-artifact-unavailable", `cannot inspect declared MCP config: ${config.error}`, path));
268
+ return;
269
+ }
270
+ const servers = isPlainObject(config.value?.mcpServers) ? config.value.mcpServers : {};
271
+ for (const entry of Object.values(servers)) {
272
+ if (!isPlainObject(entry))
273
+ continue;
274
+ for (const candidate of [entry.command, ...jsonArgs(entry)]) {
275
+ if (typeof candidate === "string" && candidate.startsWith(".")) {
276
+ declared.add(join(dirname(path), candidate));
277
+ }
278
+ }
279
+ }
280
+ };
281
+ for (const root of roots) {
282
+ for (const manifestPath of [
283
+ join(root, ".codex-plugin", "plugin.json"),
284
+ join(root, ".claude-plugin", "plugin.json")
285
+ ]) {
286
+ if (!existsSync(manifestPath))
287
+ continue;
288
+ declared.add(manifestPath);
289
+ const manifest = readJson(manifestPath);
290
+ if (manifest.error) {
291
+ report.findings.push(finding("runtime-artifact-unavailable", `cannot inspect plugin manifest: ${manifest.error}`, manifestPath));
292
+ continue;
293
+ }
294
+ const pluginRoot = dirname(dirname(manifestPath));
295
+ const mcpServers = manifest.value?.mcpServers;
296
+ if (typeof mcpServers === "string" && mcpServers.startsWith(".")) {
297
+ inspectMcpConfig(join(pluginRoot, mcpServers));
298
+ }
299
+ else if (isPlainObject(mcpServers)) {
300
+ for (const entry of Object.values(mcpServers)) {
301
+ if (!isPlainObject(entry))
302
+ continue;
303
+ for (const candidate of [entry.command, ...jsonArgs(entry)]) {
304
+ if (typeof candidate === "string" && candidate.startsWith(".")) {
305
+ declared.add(join(pluginRoot, candidate));
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+ const conventionalMcpConfig = join(root, ".mcp.json");
312
+ if (existsSync(conventionalMcpConfig))
313
+ inspectMcpConfig(conventionalMcpConfig);
314
+ }
315
+ const resolved = [];
316
+ for (const path of declared) {
317
+ try {
318
+ const target = realpathSync(path);
319
+ const metadata = statSync(target);
320
+ if (!metadata.isFile())
321
+ throw new Error("not a regular file");
322
+ pushUnique(resolved, target);
323
+ }
324
+ catch (error) {
325
+ report.findings.push(finding("runtime-artifact-unavailable", `cannot inspect declared artifact: ${error.message}`, path));
326
+ }
327
+ }
328
+ return resolved;
329
+ }
330
+ function multilineTomlStringStart(line) {
331
+ // This deliberately accepts only the assignment form which this table
332
+ // rewriter can frame safely. It is not a full TOML parser: unsupported
333
+ // regions stay byte-identical, while complex dotted keys and Unicode escape
334
+ // semantics never grant destructive authorization.
335
+ const match = /^\s*(?:[A-Za-z0-9_-]+|"(?:\\.|[^"\\])*"|'[^']*')(?:\s*\.\s*(?:[A-Za-z0-9_-]+|"(?:\\.|[^"\\])*"|'[^']*'))*\s*=\s*('''|""")/.exec(line);
336
+ return match ? { delimiter: match[1], offset: match.index + match[0].length } : undefined;
337
+ }
338
+ function multilineTomlStringEnd(line, delimiter, offset) {
339
+ for (let cursor = offset; cursor <= line.length - delimiter.length; cursor++) {
340
+ if (!line.startsWith(delimiter, cursor))
341
+ continue;
342
+ if (delimiter === '\"\"\"') {
343
+ let slashCount = 0;
344
+ for (let previous = cursor - 1; previous >= 0 && line[previous] === "\\"; previous--)
345
+ slashCount++;
346
+ if (slashCount % 2 === 1)
347
+ continue;
348
+ }
349
+ const tail = line.slice(cursor + delimiter.length);
350
+ return /^\s*(?:#.*)?(?:\r?\n)?$/.test(tail)
351
+ ? { closed: true }
352
+ : { closed: false, unavailable: "multiline TOML string has content after its closing delimiter" };
353
+ }
354
+ return { closed: false };
355
+ }
356
+ function dottedMcpRegion(line) {
357
+ const assignment = /^\s*(mcp_servers\.[^\s=]+)\s*=/.exec(line);
358
+ if (!assignment)
359
+ return undefined;
360
+ const lastSeparator = assignment[1].lastIndexOf(".");
361
+ return lastSeparator > "mcp_servers".length
362
+ ? `${assignment[1].slice(0, lastSeparator)}.*`
363
+ : undefined;
364
+ }
365
+ function parseTomlTables(content) {
366
+ const result = [];
367
+ let current;
368
+ let multilineDelimiter;
369
+ const pushCurrent = () => {
370
+ if (!current)
371
+ return;
372
+ const end = current.trailingTriviaStart ?? current.end;
373
+ if (end > current.start) {
374
+ const raw = content.slice(current.start, end);
375
+ result.push({
376
+ header: current.header,
377
+ lines: raw.split(/(?<=\n)/),
378
+ raw,
379
+ span: { start: current.start, end },
380
+ containsMultilineString: current.containsMultilineString,
381
+ opaqueReason: current.opaqueReason,
382
+ opaqueLabel: current.opaqueLabel
383
+ });
384
+ }
385
+ current = undefined;
386
+ };
387
+ const appendCurrentLine = (line, start, kind) => {
388
+ if (!current)
389
+ current = { header: "", start, end: start, containsMultilineString: false };
390
+ current.end = start + line.length;
391
+ if (kind === "content")
392
+ current.trailingTriviaStart = undefined;
393
+ else if (kind === "comment" ||
394
+ (kind === "blank" && line.replace(/\r?\n$/, "").length > 0))
395
+ current.trailingTriviaStart ??= start;
396
+ };
397
+ let offset = 0;
398
+ for (const line of content.split(/(?<=\n)/)) {
399
+ const lineStart = offset;
400
+ offset += line.length;
401
+ if (multilineDelimiter) {
402
+ if (!current)
403
+ return { tables: result, unavailable: "multiline TOML string has no containing table" };
404
+ appendCurrentLine(line, lineStart, "content");
405
+ current.containsMultilineString = true;
406
+ const end = multilineTomlStringEnd(line, multilineDelimiter, 0);
407
+ if (end.unavailable)
408
+ return { tables: result, unavailable: end.unavailable };
409
+ if (end.closed)
410
+ multilineDelimiter = undefined;
411
+ continue;
412
+ }
413
+ const arrayHeader = /^\s*\[\[([^\]]+)\]\]\s*(?:#.*)?(?:\r?\n)?$/.exec(line);
414
+ if (arrayHeader || /^\s*\[\[/.test(line)) {
415
+ pushCurrent();
416
+ current = {
417
+ header: "",
418
+ start: lineStart,
419
+ end: offset,
420
+ containsMultilineString: false,
421
+ opaqueReason: "TOML arrays of tables are not framed for targeted rewrite",
422
+ opaqueLabel: arrayHeader ? `[[${arrayHeader[1]}]]` : line.trim()
423
+ };
424
+ continue;
425
+ }
426
+ const dottedRegion = dottedMcpRegion(line);
427
+ if (dottedRegion) {
428
+ if (current?.header) {
429
+ current.opaqueReason ??= "dotted MCP keys are not framed for targeted rewrite";
430
+ current.opaqueLabel ??= dottedRegion;
431
+ appendCurrentLine(line, lineStart, "content");
432
+ }
433
+ else {
434
+ pushCurrent();
435
+ current = {
436
+ header: "",
437
+ start: lineStart,
438
+ end: offset,
439
+ containsMultilineString: false,
440
+ opaqueReason: "dotted MCP keys are not framed for targeted rewrite",
441
+ opaqueLabel: dottedRegion
442
+ };
443
+ }
444
+ continue;
445
+ }
446
+ const header = /^\s*\[([^\]]+)\]\s*(?:#.*)?(?:\r?\n)?$/.exec(line);
447
+ if (header) {
448
+ pushCurrent();
449
+ current = { header: header[1], start: lineStart, end: offset, containsMultilineString: false };
450
+ }
451
+ else {
452
+ const kind = /^\s*#.*(?:\r?\n)?$/.test(line)
453
+ ? "comment"
454
+ : /^\s*(?:\r?\n)?$/.test(line)
455
+ ? "blank"
456
+ : "content";
457
+ appendCurrentLine(line, lineStart, kind);
458
+ if (!current)
459
+ return { tables: result, unavailable: "TOML line has no containing table" };
460
+ const start = multilineTomlStringStart(line);
461
+ if (start) {
462
+ current.containsMultilineString = true;
463
+ const end = multilineTomlStringEnd(line, start.delimiter, start.offset);
464
+ if (end.unavailable)
465
+ return { tables: result, unavailable: end.unavailable };
466
+ if (!end.closed)
467
+ multilineDelimiter = start.delimiter;
468
+ }
469
+ else if (line.includes("'''") || line.includes('\"\"\"')) {
470
+ return { tables: result, unavailable: "cannot frame an unsupported triple-quoted TOML string" };
471
+ }
472
+ }
473
+ }
474
+ if (multilineDelimiter)
475
+ return { tables: result, unavailable: "unterminated multiline TOML string" };
476
+ pushCurrent();
477
+ return { tables: result };
478
+ }
479
+ function tomlQuotedName(header, prefix) {
480
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
481
+ const match = new RegExp(`^${escaped}(?:\\.\\\"([^\"]+)\\\"|\\.([^\\.]+))$`).exec(header);
482
+ return match?.[1] ?? match?.[2];
483
+ }
484
+ function tomlAssignment(table, key) {
485
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
486
+ const line = table.lines.slice(1).find((entry) => new RegExp(`^\\s*${escaped}\\s*=`).test(entry));
487
+ if (!line)
488
+ return undefined;
489
+ return line.slice(line.indexOf("=") + 1).trim().replace(/\s+#.*$/, "");
490
+ }
491
+ function tomlValue(table, key) {
492
+ const value = tomlAssignment(table, key);
493
+ if (value === undefined)
494
+ return undefined;
495
+ const quoted = /^"((?:\\.|[^"\\])*)"$/.exec(value);
496
+ if (quoted)
497
+ return quoted[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
498
+ const literal = /^'([^']*)'$/.exec(value);
499
+ return literal ? literal[1] : value;
500
+ }
501
+ function decodeTomlBasicString(value) {
502
+ if (value.length < 2 || value[0] !== '"' || value.at(-1) !== '"')
503
+ return undefined;
504
+ let decoded = "";
505
+ for (let cursor = 1; cursor < value.length - 1; cursor++) {
506
+ const character = value[cursor];
507
+ if (character !== "\\") {
508
+ if (character < " " || character === '"')
509
+ return undefined;
510
+ decoded += character;
511
+ continue;
512
+ }
513
+ const escape = value[++cursor];
514
+ if (cursor >= value.length - 1)
515
+ return undefined;
516
+ const escaped = escape === "b" ? "\b"
517
+ : escape === "t" ? "\t"
518
+ : escape === "n" ? "\n"
519
+ : escape === "f" ? "\f"
520
+ : escape === "r" ? "\r"
521
+ : escape === '"' ? '"'
522
+ : escape === "\\" ? "\\"
523
+ : undefined;
524
+ if (escaped !== undefined) {
525
+ decoded += escaped;
526
+ continue;
527
+ }
528
+ const width = escape === "u" ? 4 : escape === "U" ? 8 : undefined;
529
+ if (width === undefined)
530
+ return undefined;
531
+ const hexadecimal = value.slice(cursor + 1, cursor + 1 + width);
532
+ if (!new RegExp(`^[0-9A-Fa-f]{${width}}$`).test(hexadecimal))
533
+ return undefined;
534
+ const codePoint = Number.parseInt(hexadecimal, 16);
535
+ if (codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF))
536
+ return undefined;
537
+ decoded += String.fromCodePoint(codePoint);
538
+ cursor += width;
539
+ }
540
+ return decoded;
541
+ }
542
+ /**
543
+ * Return a fully decoded single-line TOML string, or nothing. This intentionally
544
+ * does not try to approximate multiline TOML syntax: a path that we cannot
545
+ * decode exactly must never be used as evidence that a local source is absent.
546
+ */
547
+ function tomlStringValue(table, key) {
548
+ const value = tomlAssignment(table, key);
549
+ if (value === undefined)
550
+ return undefined;
551
+ if (/^"/.test(value))
552
+ return decodeTomlBasicString(value);
553
+ const literal = /^'([^']*)'$/.exec(value);
554
+ return literal?.[1];
555
+ }
556
+ function tomlBoolean(table, key) {
557
+ const value = tomlValue(table, key);
558
+ if (value === "true")
559
+ return true;
560
+ if (value === "false")
561
+ return false;
562
+ return undefined;
563
+ }
564
+ function rewriteTomlTables(raw, transform) {
565
+ const parsed = parseTomlTables(raw);
566
+ if (parsed.unavailable)
567
+ throw new Error(`cannot safely frame TOML tables: ${parsed.unavailable}`);
568
+ const output = [];
569
+ let cursor = 0;
570
+ for (const table of parsed.tables) {
571
+ if (!table.span)
572
+ throw new Error("cannot safely frame TOML table span");
573
+ output.push(raw.slice(cursor, table.span.start));
574
+ const replacement = transform(table);
575
+ if (replacement)
576
+ output.push(...replacement);
577
+ cursor = table.span.end;
578
+ }
579
+ output.push(raw.slice(cursor));
580
+ return output.join("");
581
+ }
582
+ function isNamedOpaqueTomlArray(table) {
583
+ return (table.opaqueReason === "TOML arrays of tables are not framed for targeted rewrite" &&
584
+ /^\[\[[^\]\r\n]+\]\]$/.test(table.opaqueLabel ?? ""));
585
+ }
586
+ function reportOpaqueTomlRegions(report, path, tables) {
587
+ for (const table of tables) {
588
+ if (!table.opaqueReason)
589
+ continue;
590
+ const message = `opaque Codex TOML region ${table.opaqueLabel ?? "<unknown>"}: ${table.opaqueReason}; its existing bytes were retained and no rewrite was attempted.`;
591
+ if (isNamedOpaqueTomlArray(table)) {
592
+ report.preserved.push(finding("config-preserved", `preserved ${message}`, path));
593
+ }
594
+ else {
595
+ report.unrepaired.push(finding("config-invalid", `cannot rewrite ${message}`, path));
596
+ }
597
+ }
598
+ }
599
+ function tomlTable(header, lines) {
600
+ return [`[${header}]\n`, ...lines.map((line) => `${line}\n`), "\n"];
601
+ }
602
+ function isLegacySentropicName(name) {
603
+ return Boolean(name && /^sentropic-local-/i.test(name));
604
+ }
605
+ function isLegacyH2aPlugin(name) {
606
+ return Boolean(name && (/^h2a-local-/i.test(name) || /^h2a@sentropic-local-/i.test(name)));
607
+ }
608
+ function isAuthorizedClaudePluginUninstall(name) {
609
+ return Boolean(name && (/^h2a-local-/i.test(name) ||
610
+ /^h2a@sentropic-local-/i.test(name) ||
611
+ /^track@sentropic$/i.test(name)));
612
+ }
613
+ function isDirectH2aMcp(table) {
614
+ const command = tomlValue(table, "command");
615
+ const args = tomlValue(table, "args") ?? "";
616
+ return command === "h2a" && /(?:^|[\"\s,])mcp-serve(?:[\"\s,]|$)/.test(args);
617
+ }
618
+ function isDirectTrackMcp(table) {
619
+ const command = tomlValue(table, "command") ?? "";
620
+ const rawArgs = tomlValue(table, "args") ?? "";
621
+ try {
622
+ const args = JSON.parse(rawArgs);
623
+ return command === "h2a" && Array.isArray(args) && args.includes("track-mcp");
624
+ }
625
+ catch {
626
+ return false;
627
+ }
628
+ }
629
+ function isOwnedMarketplace(location) {
630
+ const marketplace = readJson(join(location, ".claude-plugin", "marketplace.json")).value;
631
+ return Boolean(marketplace?.name === H2A_MARKETPLACE_NAME &&
632
+ Array.isArray(marketplace.plugins) &&
633
+ marketplace.plugins.some((plugin) => isPlainObject(plugin) && plugin.name === "h2a" && plugin.source === "./packages/h2a"));
634
+ }
635
+ function canonicalMarketplace(value) {
636
+ if (!isPlainObject(value))
637
+ return false;
638
+ const source = value.source;
639
+ return (isPlainObject(source) &&
640
+ source.source === "github" &&
641
+ source.repo === H2A_MARKETPLACE_REPOSITORY &&
642
+ typeof value.installLocation === "string" &&
643
+ isOwnedMarketplace(value.installLocation));
644
+ }
645
+ function canonicalCodexMarketplace(table, home) {
646
+ return Boolean(canonicalCodexMarketplaceConfig(table) &&
647
+ isOwnedMarketplace(join(codexRoot(home), ".tmp", "marketplaces", H2A_MARKETPLACE_NAME)));
648
+ }
649
+ function isOwnedCodexMarketplace(table, home) {
650
+ return canonicalCodexMarketplace(table, home) || isOwnedLegacyCodexMarketplace(table);
651
+ }
652
+ function hasOwnedCanonicalCodexPlugin(home) {
653
+ const cache = join(codexRoot(home), "plugins", "cache", H2A_MARKETPLACE_NAME, "h2a");
654
+ return listDirectories(cache).some((version) => isOwnedPlugin(join(cache, version)));
655
+ }
656
+ /** A configured Git source is distinct from its derived local marketplace cache. */
657
+ function canonicalCodexMarketplaceConfig(table) {
658
+ return Boolean(table &&
659
+ tomlValue(table, "source_type") === "git" &&
660
+ tomlValue(table, "source") === H2A_MARKETPLACE_GIT_URL &&
661
+ tomlValue(table, "ref") === "main");
662
+ }
663
+ function h2aPluginEntries(value) {
664
+ return Array.isArray(value) ? value.filter(isPlainObject) : [];
665
+ }
666
+ function pluginEntryIsCurrent(entry, version) {
667
+ return (entry.version === version &&
668
+ typeof entry.installPath === "string" &&
669
+ isOwnedPlugin(entry.installPath) &&
670
+ cachedPluginVersion(entry.installPath) === version);
671
+ }
672
+ function cachedPluginVersion(path) {
673
+ for (const manifest of [
674
+ join(path, ".codex-plugin", "plugin.json"),
675
+ join(path, ".claude-plugin", "plugin.json")
676
+ ]) {
677
+ const parsed = readJson(manifest).value;
678
+ if (typeof parsed?.version === "string")
679
+ return parsed.version;
680
+ }
681
+ return undefined;
682
+ }
683
+ function isOwnedPlugin(path, expectedName = "h2a") {
684
+ return [
685
+ join(path, ".codex-plugin", "plugin.json"),
686
+ join(path, ".claude-plugin", "plugin.json")
687
+ ].some((manifest) => readJson(manifest).value?.name === expectedName);
688
+ }
689
+ function pluginEntriesAreOwned(selector, value) {
690
+ const expectedName = selector === "track@sentropic" ? "track" : "h2a";
691
+ const entries = h2aPluginEntries(value);
692
+ return entries.length > 0 && entries.every((entry) => typeof entry.installPath === "string" && isOwnedPlugin(entry.installPath, expectedName));
693
+ }
694
+ function isOwnedLegacyCacheRoot(path) {
695
+ try {
696
+ const entries = readdirSync(path, { withFileTypes: true });
697
+ if (entries.length !== 1 || !entries[0].isDirectory() || entries[0].name !== "h2a")
698
+ return false;
699
+ const versions = readdirSync(join(path, "h2a"), { withFileTypes: true });
700
+ return versions.length > 0 && versions.every((entry) => entry.isDirectory() && isOwnedPlugin(join(path, "h2a", entry.name)));
701
+ }
702
+ catch {
703
+ return false;
704
+ }
705
+ }
706
+ function cacheVersionMatches(path, version) {
707
+ return cachedPluginVersion(path) === version;
708
+ }
709
+ function codexCacheVersions(home) {
710
+ return listDirectories(join(codexRoot(home), "plugins", "cache", H2A_MARKETPLACE_NAME, "h2a"));
711
+ }
712
+ function claudeCacheVersions(home) {
713
+ return listDirectories(join(claudeRoot(home), "plugins", "cache", H2A_MARKETPLACE_NAME, "h2a"));
714
+ }
715
+ function codexRoot(home) {
716
+ return resolveHostConfigRoot("codex", home);
717
+ }
718
+ function claudeRoot(home) {
719
+ return resolveHostConfigRoot("claude", home);
720
+ }
721
+ function codexConfigPath(home) {
722
+ return join(codexRoot(home), "config.toml");
723
+ }
724
+ function claudeSettingsPath(home) {
725
+ return join(claudeRoot(home), "settings.json");
726
+ }
727
+ function claudeConfigPaths(home) {
728
+ const base = resolveHostConfigCompanionBase("claude", home);
729
+ return [join(base, ".claude.json"), join(base, ".config", "claude", "mcp.json")].filter(existsSync);
730
+ }
731
+ function isProvenAbsentFilesystemError(error) {
732
+ const code = error.code;
733
+ return code === "ENOENT" || code === "ENOTDIR";
734
+ }
735
+ function hostConfigurationArtifactState(path) {
736
+ try {
737
+ lstatSync(path);
738
+ }
739
+ catch (error) {
740
+ return isProvenAbsentFilesystemError(error)
741
+ ? { state: "absent" }
742
+ : { state: "unavailable", reason: error.code ?? error.message };
743
+ }
744
+ try {
745
+ statSync(path);
746
+ return { state: "present" };
747
+ }
748
+ catch (error) {
749
+ // lstat succeeded, so ENOENT here is a broken symlink, not proof of absence.
750
+ return { state: "unavailable", reason: error.code ?? error.message };
751
+ }
752
+ }
753
+ function explicitlyConfiguredHostRoot(host) {
754
+ const environment = host === "claude" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME";
755
+ const path = process.env[environment];
756
+ return path && path.length > 0 ? { environment, path } : undefined;
757
+ }
758
+ function hostConfigurationArtifacts(home, host) {
759
+ const explicitlyConfigured = explicitlyConfiguredHostRoot(host);
760
+ if (explicitlyConfigured) {
761
+ try {
762
+ if (!statSync(explicitlyConfigured.path).isDirectory()) {
763
+ return {
764
+ present: [],
765
+ unavailable: [{
766
+ path: explicitlyConfigured.path,
767
+ reason: `${explicitlyConfigured.environment} is explicitly configured but is not a directory`
768
+ }]
769
+ };
770
+ }
771
+ }
772
+ catch (error) {
773
+ return {
774
+ present: [],
775
+ unavailable: [{
776
+ path: explicitlyConfigured.path,
777
+ reason: `${explicitlyConfigured.environment} is explicitly configured but cannot be inspected (${error.code ?? error.message})`
778
+ }]
779
+ };
780
+ }
781
+ }
782
+ const candidates = host === "codex"
783
+ ? [codexRoot(home), codexConfigPath(home)]
784
+ : [
785
+ claudeRoot(home),
786
+ claudeSettingsPath(home),
787
+ join(resolveHostConfigCompanionBase("claude", home), ".claude.json"),
788
+ join(resolveHostConfigCompanionBase("claude", home), ".config", "claude", "mcp.json")
789
+ ];
790
+ const present = [];
791
+ const unavailable = [];
792
+ for (const path of candidates) {
793
+ const result = hostConfigurationArtifactState(path);
794
+ if (result.state === "present")
795
+ present.push(path);
796
+ if (result.state === "unavailable")
797
+ unavailable.push({ path, reason: result.reason ?? "unknown error" });
798
+ }
799
+ return { present, unavailable };
800
+ }
801
+ function hostCliCanRunNativeRepair(host, hasInjectedRunner, testHostCliReachable) {
802
+ if (testHostCliReachable)
803
+ return { state: testHostCliReachable(host) ? "reachable" : "absent" };
804
+ // A runner is the hermetic test double for a reachable native CLI. Production
805
+ // never supplies one, so it always resolves the actual executable on PATH.
806
+ if (hasInjectedRunner)
807
+ return { state: "reachable" };
808
+ let unavailable;
809
+ for (const segment of (process.env.PATH ?? "").split(delimiter)) {
810
+ // POSIX defines an empty PATH segment as the current working directory.
811
+ const directory = segment.length === 0 ? "." : segment;
812
+ try {
813
+ const metadata = statSync(join(directory, host));
814
+ if (metadata.isFile() && (metadata.mode & 0o111) !== 0)
815
+ return { state: "reachable" };
816
+ }
817
+ catch (error) {
818
+ if (!isProvenAbsentFilesystemError(error) && !unavailable) {
819
+ unavailable = {
820
+ path: directory,
821
+ reason: error.code ?? error.message
822
+ };
823
+ }
824
+ }
825
+ }
826
+ return unavailable
827
+ ? { state: "unavailable", unavailablePath: unavailable.path, unavailableReason: unavailable.reason }
828
+ : { state: "absent" };
829
+ }
830
+ function absentHostReport(home, host) {
831
+ const hostName = host === "claude" ? "Claude" : "Codex";
832
+ const report = {
833
+ host,
834
+ findings: [],
835
+ diagnostics: [],
836
+ changed: [],
837
+ preserved: [],
838
+ unrepaired: [],
839
+ coherencePaths: [],
840
+ plannedActions: [],
841
+ repairMarkerPath: repairMarkerPath(home, host)
842
+ };
843
+ report.findings.push(finding("host-not-installed", `${hostName} is not installed: its CLI is absent from PATH and no host configuration artifacts were found.`));
844
+ return report;
845
+ }
846
+ function unavailableHostConfigurationReport(home, host, unavailable) {
847
+ const hostName = host === "claude" ? "Claude" : "Codex";
848
+ const report = {
849
+ host,
850
+ findings: [],
851
+ diagnostics: [],
852
+ changed: [],
853
+ preserved: [],
854
+ unrepaired: [],
855
+ coherencePaths: [],
856
+ plannedActions: [],
857
+ repairMarkerPath: repairMarkerPath(home, host)
858
+ };
859
+ report.findings.push(finding("host-config-unavailable", `${hostName} configuration cannot be inspected safely: ${unavailable.map((entry) => `${entry.path} (${entry.reason})`).join(", ")}.`, unavailable[0]?.path));
860
+ return report;
861
+ }
862
+ function unavailableHostCliReport(home, host, cli) {
863
+ const hostName = host === "claude" ? "Claude" : "Codex";
864
+ const report = {
865
+ host,
866
+ findings: [],
867
+ diagnostics: [],
868
+ changed: [],
869
+ preserved: [],
870
+ unrepaired: [],
871
+ coherencePaths: [],
872
+ plannedActions: [],
873
+ repairMarkerPath: repairMarkerPath(home, host)
874
+ };
875
+ report.findings.push(finding("host-cli-unavailable", `${hostName} CLI cannot be inspected safely on PATH: ${cli.unavailablePath} (${cli.unavailableReason}).`, cli.unavailablePath));
876
+ return report;
877
+ }
878
+ function unreachableHostCliDiagnostic(host, artifacts) {
879
+ const hostName = host === "claude" ? "Claude" : "Codex";
880
+ return finding("host-cli-unreachable", `${hostName} CLI could not be reached on PATH; native repair is unavailable while configuration artifacts remain: ${artifacts.join(", ")}.`, artifacts[0]);
881
+ }
882
+ function claudeEnabledPlugins(settings) {
883
+ return isPlainObject(settings?.enabledPlugins) ? settings.enabledPlugins : {};
884
+ }
885
+ function claudeExtraKnownMarketplaces(settings) {
886
+ return isPlainObject(settings?.extraKnownMarketplaces) ? settings.extraKnownMarketplaces : {};
887
+ }
888
+ function shellQuote(path) {
889
+ return `'${path.replace(/'/g, "'\\''")}'`;
890
+ }
891
+ function orphanCacheFinding(host, directories, paths, cacheRoot) {
892
+ return finding("orphan-cache", `${host} has orphan H2A cache directories: ${directories.join(", ")}. Remove them manually with: rm -rf -- ${paths.map(shellQuote).join(" ")}`, cacheRoot);
893
+ }
894
+ function pushUnique(values, value) {
895
+ if (!values.includes(value))
896
+ values.push(value);
897
+ }
898
+ function planAction(report, action) {
899
+ pushUnique(report.plannedActions, action);
900
+ }
901
+ function repairMarkerPath(home, host) {
902
+ return join(host === "codex" ? codexRoot(home) : claudeRoot(home), "h2a-repair.json");
903
+ }
904
+ function readRepairMarker(path) {
905
+ if (!existsSync(path))
906
+ return {};
907
+ try {
908
+ const value = JSON.parse(readFileSync(path, "utf8"));
909
+ const repairedAt = isPlainObject(value) ? value.repairedAt : undefined;
910
+ const repairedPaths = isPlainObject(value) ? value.repairedPaths : undefined;
911
+ if (typeof repairedAt !== "string" ||
912
+ !Number.isFinite(Date.parse(repairedAt)) ||
913
+ !Array.isArray(repairedPaths) ||
914
+ repairedPaths.length === 0 ||
915
+ !repairedPaths.every((entry) => typeof entry === "string" && entry.length > 0)) {
916
+ return { error: "missing a valid repairedAt timestamp or repairedPaths" };
917
+ }
918
+ return { marker: { path, repairedAt, repairedPaths } };
919
+ }
920
+ catch (error) {
921
+ return { error: error.message };
922
+ }
923
+ }
924
+ function inspectRepairMarker(home, report) {
925
+ const path = repairMarkerPath(home, report.host);
926
+ report.repairMarkerPath = path;
927
+ const result = readRepairMarker(path);
928
+ if (result.marker) {
929
+ report.repairMarker = result.marker;
930
+ }
931
+ else if (result.error) {
932
+ report.findings.push(finding("repair-marker-unavailable", `cannot read host repair marker: ${result.error}`, path));
933
+ }
934
+ }
935
+ function recordRepairMarker(home, report, repairedPaths, writeMarker) {
936
+ const uniquePaths = [...new Set(repairedPaths)];
937
+ if (uniquePaths.length === 0)
938
+ return;
939
+ const path = repairMarkerPath(home, report.host);
940
+ const repairedAt = new Date().toISOString();
941
+ try {
942
+ mkdirSync(dirname(path), { recursive: true });
943
+ writeMarker(path, `${JSON.stringify({ repairedAt, repairedPaths: uniquePaths })}\n`);
944
+ const result = readRepairMarker(path);
945
+ if (!result.marker)
946
+ throw new Error(result.error ?? "marker disappeared after write");
947
+ report.repairMarker = result.marker;
948
+ pushUnique(report.changed, path);
949
+ }
950
+ catch (error) {
951
+ report.unrepaired.push(finding("repair-marker-write-failed", `cannot write host repair marker: ${error.message}`, path));
952
+ }
953
+ }
954
+ function inspectCodex(home, version) {
955
+ const configPath = codexConfigPath(home);
956
+ const cachePath = join(codexRoot(home), "plugins", "cache");
957
+ const currentCachePath = join(cachePath, H2A_MARKETPLACE_NAME, "h2a", version);
958
+ const report = {
959
+ host: "codex",
960
+ findings: [],
961
+ diagnostics: [],
962
+ changed: [],
963
+ preserved: [],
964
+ unrepaired: [],
965
+ coherencePaths: [],
966
+ plannedActions: [],
967
+ repairMarkerPath: repairMarkerPath(home, "codex")
968
+ };
969
+ let raw = "";
970
+ try {
971
+ raw = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
972
+ }
973
+ catch (error) {
974
+ report.findings.push(finding("config-invalid", `cannot read Codex config: ${error.message}`, configPath));
975
+ return report;
976
+ }
977
+ const parsed = parseTomlTables(raw);
978
+ if (parsed.unavailable) {
979
+ report.findings.push(finding("config-invalid", `cannot safely frame Codex TOML: ${parsed.unavailable}`, configPath));
980
+ return report;
981
+ }
982
+ const tables = parsed.tables;
983
+ const marketplaceTables = tables.filter((table) => tomlQuotedName(table.header, "marketplaces") !== undefined);
984
+ const canonical = marketplaceTables.find((table) => tomlQuotedName(table.header, "marketplaces") === H2A_MARKETPLACE_NAME);
985
+ if (!canonicalCodexMarketplace(canonical, home)) {
986
+ const configuredSource = canonical ? tomlValue(canonical, "source") : undefined;
987
+ report.findings.push(finding("marketplace-missing", configuredSource
988
+ ? `Codex lacks the canonical sentropic Git marketplace: configured source ${configuredSource} is not the canonical Git source; an enabled h2a MCP/plugin cache alone is an orphaned false-healthy signal.`
989
+ : "Codex lacks the canonical sentropic Git marketplace; an enabled h2a MCP/plugin cache alone is an orphaned false-healthy signal.", configPath));
990
+ }
991
+ const staleMarketplaces = marketplaceTables
992
+ .map((table) => tomlQuotedName(table.header, "marketplaces"))
993
+ .filter(isLegacySentropicName);
994
+ if (staleMarketplaces.length > 0) {
995
+ report.findings.push(finding("marketplace-stale", `Codex has stale Sentropic marketplaces: ${staleMarketplaces.join(", ")}.`, configPath));
996
+ }
997
+ const plugins = tables.filter((table) => tomlQuotedName(table.header, "plugins") !== undefined);
998
+ const canonicalPlugin = plugins.find((table) => tomlQuotedName(table.header, "plugins") === H2A_PLUGIN_SELECTOR);
999
+ if (tomlBoolean(canonicalPlugin ?? { header: "", lines: [] }, "enabled") !== true) {
1000
+ report.findings.push(finding("plugin-missing", "Codex does not enable the canonical h2a@sentropic plugin.", configPath));
1001
+ }
1002
+ const stalePlugins = plugins
1003
+ .filter((table) => tomlBoolean(table, "enabled") !== false)
1004
+ .map((table) => tomlQuotedName(table.header, "plugins"))
1005
+ .filter(isLegacyH2aPlugin);
1006
+ if (stalePlugins.length > 0) {
1007
+ report.findings.push(finding("plugin-stale", `Codex has stale H2A plugin entries: ${stalePlugins.join(", ")}.`, configPath));
1008
+ }
1009
+ const versions = codexCacheVersions(home);
1010
+ if (!versions.includes(version) || !cacheVersionMatches(currentCachePath, version)) {
1011
+ report.findings.push(finding("version-skew", `Codex h2a cache is not at npm CLI version ${version}.`, cachePath));
1012
+ }
1013
+ const staleVersions = versions.filter((entry) => entry !== version && isOwnedPlugin(join(cachePath, H2A_MARKETPLACE_NAME, "h2a", entry)));
1014
+ const legacyCaches = listDirectories(cachePath).filter((entry) => isLegacySentropicName(entry) && isOwnedLegacyCacheRoot(join(cachePath, entry)));
1015
+ if (staleVersions.length > 0 || legacyCaches.length > 0) {
1016
+ report.findings.push(orphanCacheFinding("Codex", [...staleVersions, ...legacyCaches], [
1017
+ ...staleVersions.map((entry) => join(cachePath, H2A_MARKETPLACE_NAME, "h2a", entry)),
1018
+ ...legacyCaches.map((entry) => join(cachePath, entry))
1019
+ ], cachePath));
1020
+ }
1021
+ const mcp = tables.filter((table) => tomlQuotedName(table.header, "mcp_servers") !== undefined);
1022
+ const directH2a = mcp.filter(isDirectH2aMcp);
1023
+ const directTrack = mcp.filter(isDirectTrackMcp);
1024
+ const endpointCount = (tomlBoolean(canonicalPlugin ?? { header: "", lines: [] }, "enabled") === true ? 1 : 0) + directH2a.length;
1025
+ if (endpointCount !== 1) {
1026
+ report.findings.push(finding("h2a-endpoint-count", `Codex exposes ${endpointCount} H2A endpoints; exactly one plugin endpoint is required.`, configPath));
1027
+ }
1028
+ if (directTrack.length > 0) {
1029
+ report.findings.push(finding("standalone-track-mcp", "Codex has a standalone track-mcp endpoint; track_* must be served by h2a.", configPath));
1030
+ }
1031
+ report.coherencePaths.push(...loadBearingArtifacts(report, [currentCachePath], [configPath]));
1032
+ inspectRepairMarker(home, report);
1033
+ return report;
1034
+ }
1035
+ function inspectClaude(home, version) {
1036
+ const knownPath = join(claudeRoot(home), "plugins", "known_marketplaces.json");
1037
+ const installedPath = join(claudeRoot(home), "plugins", "installed_plugins.json");
1038
+ const settingsPath = claudeSettingsPath(home);
1039
+ const report = {
1040
+ host: "claude",
1041
+ findings: [],
1042
+ diagnostics: [],
1043
+ changed: [],
1044
+ preserved: [],
1045
+ unrepaired: [],
1046
+ coherencePaths: [],
1047
+ plannedActions: [],
1048
+ repairMarkerPath: repairMarkerPath(home, "claude")
1049
+ };
1050
+ const known = readJson(knownPath);
1051
+ const settings = readJson(settingsPath);
1052
+ if (known.error) {
1053
+ report.findings.push(finding("config-invalid", `cannot parse Claude marketplace state: ${known.error}`, knownPath));
1054
+ }
1055
+ else if (!canonicalMarketplace(known.value?.[H2A_MARKETPLACE_NAME])) {
1056
+ report.findings.push(finding("marketplace-missing", "Claude lacks the canonical sentropic Git marketplace.", knownPath));
1057
+ }
1058
+ if (settings.error) {
1059
+ report.findings.push(finding("config-invalid", `cannot parse Claude settings: ${settings.error}`, settingsPath));
1060
+ }
1061
+ const staleMarketplaces = [...new Set([
1062
+ ...Object.keys(known.value ?? {}).filter(isLegacySentropicName),
1063
+ ...Object.keys(claudeExtraKnownMarketplaces(settings.value)).filter(isLegacySentropicName)
1064
+ ])];
1065
+ if (staleMarketplaces.length > 0) {
1066
+ report.findings.push(finding("marketplace-stale", `Claude has stale Sentropic marketplaces: ${staleMarketplaces.join(", ")}.`, staleMarketplaces.some((name) => Object.hasOwn(claudeExtraKnownMarketplaces(settings.value), name)) ? settingsPath : knownPath));
1067
+ }
1068
+ const installed = readJson(installedPath);
1069
+ if (installed.error) {
1070
+ report.findings.push(finding("config-invalid", `cannot parse Claude plugin state: ${installed.error}`, installedPath));
1071
+ }
1072
+ const plugins = isPlainObject(installed.value?.plugins) ? installed.value.plugins : {};
1073
+ const canonicalEntries = h2aPluginEntries(plugins[H2A_PLUGIN_SELECTOR]);
1074
+ if (!canonicalEntries.some((entry) => pluginEntryIsCurrent(entry, version))) {
1075
+ report.findings.push(finding("version-skew", `Claude h2a plugin does not match npm CLI version ${version}.`, installedPath));
1076
+ }
1077
+ if (canonicalEntries.length === 0) {
1078
+ report.findings.push(finding("plugin-missing", "Claude does not install the canonical h2a@sentropic plugin.", installedPath));
1079
+ }
1080
+ const enabledPlugins = claudeEnabledPlugins(settings.value);
1081
+ const stalePlugins = Object.keys(plugins).filter((name) => (isLegacyH2aPlugin(name) || /^track@sentropic$/i.test(name)) &&
1082
+ enabledPlugins[name] !== false);
1083
+ if (stalePlugins.length > 0) {
1084
+ report.findings.push(finding("plugin-stale", `Claude has stale Sentropic plugin entries: ${stalePlugins.join(", ")}.`, installedPath));
1085
+ }
1086
+ const cacheVersions = claudeCacheVersions(home);
1087
+ const cacheRoot = join(claudeRoot(home), "plugins", "cache");
1088
+ const staleVersions = cacheVersions.filter((entry) => entry !== version && isOwnedPlugin(join(cacheRoot, H2A_MARKETPLACE_NAME, "h2a", entry)));
1089
+ const legacyCaches = listDirectories(cacheRoot).filter((entry) => isLegacySentropicName(entry) && isOwnedLegacyCacheRoot(join(cacheRoot, entry)));
1090
+ if (staleVersions.length > 0 || legacyCaches.length > 0) {
1091
+ report.findings.push(orphanCacheFinding("Claude", [...staleVersions, ...legacyCaches], [
1092
+ ...staleVersions.map((entry) => join(cacheRoot, H2A_MARKETPLACE_NAME, "h2a", entry)),
1093
+ ...legacyCaches.map((entry) => join(cacheRoot, entry))
1094
+ ], cacheRoot));
1095
+ }
1096
+ let directH2a = 0;
1097
+ let directTrack = 0;
1098
+ for (const path of claudeConfigPaths(home)) {
1099
+ const config = readJson(path);
1100
+ if (config.error) {
1101
+ report.findings.push(finding("config-invalid", `cannot parse Claude MCP config: ${config.error}`, path));
1102
+ continue;
1103
+ }
1104
+ const servers = isPlainObject(config.value?.mcpServers) ? config.value.mcpServers : {};
1105
+ for (const entry of Object.values(servers)) {
1106
+ if (isH2aJsonMcp(entry))
1107
+ directH2a++;
1108
+ if (isTrackJsonMcp(entry))
1109
+ directTrack++;
1110
+ }
1111
+ }
1112
+ const endpointCount = (canonicalEntries.length > 0 ? 1 : 0) + directH2a;
1113
+ if (endpointCount !== 1) {
1114
+ report.findings.push(finding("h2a-endpoint-count", `Claude exposes ${endpointCount} H2A endpoints; exactly one plugin endpoint is required.`));
1115
+ }
1116
+ if (directTrack > 0) {
1117
+ report.findings.push(finding("standalone-track-mcp", "Claude has a standalone track-mcp endpoint; track_* must be served by h2a."));
1118
+ }
1119
+ report.coherencePaths.push(...loadBearingArtifacts(report, canonicalEntries.flatMap((entry) => typeof entry.installPath === "string" ? [entry.installPath] : []), claudeConfigPaths(home)));
1120
+ inspectRepairMarker(home, report);
1121
+ return report;
1122
+ }
1123
+ function jsonArgs(value) {
1124
+ return isPlainObject(value) && Array.isArray(value.args)
1125
+ ? value.args.filter((entry) => typeof entry === "string")
1126
+ : [];
1127
+ }
1128
+ function isH2aJsonMcp(value) {
1129
+ if (!isPlainObject(value) || typeof value.command !== "string")
1130
+ return false;
1131
+ return (value.command === "h2a" || /[\\/]h2a(?:\.cmd|\.exe)?$/i.test(value.command)) && jsonArgs(value).includes("mcp-serve");
1132
+ }
1133
+ function isTrackJsonMcp(value) {
1134
+ if (!isPlainObject(value) || typeof value.command !== "string")
1135
+ return false;
1136
+ return value.command === "h2a" && jsonArgs(value).includes("track-mcp");
1137
+ }
1138
+ function skipJsonWhitespace(raw, index) {
1139
+ while (/\s/.test(raw[index] ?? ""))
1140
+ index++;
1141
+ return index;
1142
+ }
1143
+ function jsonStringEnd(raw, index) {
1144
+ if (raw[index] !== '"')
1145
+ return undefined;
1146
+ for (let cursor = index + 1; cursor < raw.length; cursor++) {
1147
+ if (raw[cursor] === "\\") {
1148
+ cursor++;
1149
+ continue;
1150
+ }
1151
+ if (raw[cursor] === '"')
1152
+ return cursor + 1;
1153
+ }
1154
+ return undefined;
1155
+ }
1156
+ function jsonValueEnd(raw, index) {
1157
+ if (raw[index] === '"')
1158
+ return jsonStringEnd(raw, index);
1159
+ if (raw[index] === "{" || raw[index] === "[") {
1160
+ const stack = [raw[index] === "{" ? "}" : "]"];
1161
+ for (let cursor = index + 1; cursor < raw.length; cursor++) {
1162
+ const char = raw[cursor];
1163
+ if (char === '"') {
1164
+ const end = jsonStringEnd(raw, cursor);
1165
+ if (end === undefined)
1166
+ return undefined;
1167
+ cursor = end - 1;
1168
+ continue;
1169
+ }
1170
+ if (char === "{")
1171
+ stack.push("}");
1172
+ else if (char === "[")
1173
+ stack.push("]");
1174
+ else if (char === "}" || char === "]") {
1175
+ if (stack.pop() !== char)
1176
+ return undefined;
1177
+ if (stack.length === 0)
1178
+ return cursor + 1;
1179
+ }
1180
+ }
1181
+ return undefined;
1182
+ }
1183
+ let cursor = index;
1184
+ while (cursor < raw.length && !/[\s,}\]]/.test(raw[cursor]))
1185
+ cursor++;
1186
+ return cursor > index ? cursor : undefined;
1187
+ }
1188
+ function jsonObjectProperties(raw, objectStart) {
1189
+ if (raw[objectStart] !== "{")
1190
+ return undefined;
1191
+ const properties = [];
1192
+ let cursor = skipJsonWhitespace(raw, objectStart + 1);
1193
+ while (raw[cursor] !== "}") {
1194
+ const start = cursor;
1195
+ const keyEnd = jsonStringEnd(raw, cursor);
1196
+ if (keyEnd === undefined)
1197
+ return undefined;
1198
+ let key;
1199
+ try {
1200
+ key = JSON.parse(raw.slice(cursor, keyEnd));
1201
+ }
1202
+ catch {
1203
+ return undefined;
1204
+ }
1205
+ cursor = skipJsonWhitespace(raw, keyEnd);
1206
+ if (raw[cursor] !== ":")
1207
+ return undefined;
1208
+ const valueStart = skipJsonWhitespace(raw, cursor + 1);
1209
+ const valueEnd = jsonValueEnd(raw, valueStart);
1210
+ if (valueEnd === undefined)
1211
+ return undefined;
1212
+ properties.push({ key, start, end: valueEnd, valueStart, valueEnd });
1213
+ cursor = skipJsonWhitespace(raw, valueEnd);
1214
+ if (raw[cursor] === ",") {
1215
+ cursor = skipJsonWhitespace(raw, cursor + 1);
1216
+ }
1217
+ else if (raw[cursor] !== "}") {
1218
+ return undefined;
1219
+ }
1220
+ }
1221
+ return properties;
1222
+ }
1223
+ function removeOwnedJsonMcpEntries(raw) {
1224
+ const root = jsonObjectProperties(raw, skipJsonWhitespace(raw, 0));
1225
+ const mcpServers = root?.find((property) => property.key === "mcpServers");
1226
+ if (!mcpServers || raw[mcpServers.valueStart] !== "{")
1227
+ return raw;
1228
+ const servers = jsonObjectProperties(raw, mcpServers.valueStart);
1229
+ if (!servers)
1230
+ return undefined;
1231
+ const removals = servers
1232
+ .map((property, index) => {
1233
+ try {
1234
+ const entry = JSON.parse(raw.slice(property.valueStart, property.valueEnd));
1235
+ return isH2aJsonMcp(entry) || isTrackJsonMcp(entry) ? index : undefined;
1236
+ }
1237
+ catch {
1238
+ return undefined;
1239
+ }
1240
+ })
1241
+ .filter((index) => index !== undefined);
1242
+ if (removals.length === 0)
1243
+ return raw;
1244
+ const ranges = [];
1245
+ for (let start = 0; start < removals.length;) {
1246
+ let end = start;
1247
+ while (end + 1 < removals.length && removals[end + 1] === removals[end] + 1)
1248
+ end++;
1249
+ const first = removals[start];
1250
+ const last = removals[end];
1251
+ ranges.push(last < servers.length - 1
1252
+ ? [servers[first].start, servers[last + 1].start]
1253
+ : first === 0
1254
+ ? [servers[first].start, servers[last].end]
1255
+ : [servers[first - 1].end, servers[last].end]);
1256
+ start = end + 1;
1257
+ }
1258
+ return ranges.reverse().reduce((rendered, [start, end]) => rendered.slice(0, start) + rendered.slice(end), raw);
1259
+ }
1260
+ function removeOwnedClaudeMarketplaceEntries(raw) {
1261
+ const root = jsonObjectProperties(raw, skipJsonWhitespace(raw, 0));
1262
+ const extraKnownMarketplaces = root?.find((property) => property.key === "extraKnownMarketplaces");
1263
+ if (!extraKnownMarketplaces)
1264
+ return { rendered: raw, unverified: [] };
1265
+ if (raw[extraKnownMarketplaces.valueStart] !== "{")
1266
+ return undefined;
1267
+ const marketplaces = jsonObjectProperties(raw, extraKnownMarketplaces.valueStart);
1268
+ if (!marketplaces)
1269
+ return undefined;
1270
+ const removals = [];
1271
+ const unverified = [];
1272
+ for (const [index, property] of marketplaces.entries()) {
1273
+ if (!isLegacySentropicName(property.key))
1274
+ continue;
1275
+ try {
1276
+ if (claudeSettingsMarketplaceIsOwned(JSON.parse(raw.slice(property.valueStart, property.valueEnd))))
1277
+ removals.push(index);
1278
+ else
1279
+ unverified.push(property.key);
1280
+ }
1281
+ catch {
1282
+ return undefined;
1283
+ }
1284
+ }
1285
+ if (removals.length === 0)
1286
+ return { rendered: raw, unverified };
1287
+ const ranges = [];
1288
+ for (let start = 0; start < removals.length;) {
1289
+ let end = start;
1290
+ while (end + 1 < removals.length && removals[end + 1] === removals[end] + 1)
1291
+ end++;
1292
+ const first = removals[start];
1293
+ const last = removals[end];
1294
+ ranges.push(last < marketplaces.length - 1
1295
+ ? [marketplaces[first].start, marketplaces[last + 1].start]
1296
+ : first === 0
1297
+ ? [marketplaces[first].start, marketplaces[last].end]
1298
+ : [marketplaces[first - 1].end, marketplaces[last].end]);
1299
+ start = end + 1;
1300
+ }
1301
+ return {
1302
+ rendered: ranges.reverse().reduce((rendered, [start, end]) => rendered.slice(0, start) + rendered.slice(end), raw),
1303
+ unverified
1304
+ };
1305
+ }
1306
+ function setTomlValue(table, key, value) {
1307
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1308
+ let replaced = false;
1309
+ const lines = table.lines.map((line, index) => {
1310
+ if (index === 0 || !new RegExp(`^\\s*${escaped}\\s*=`).test(line))
1311
+ return line;
1312
+ replaced = true;
1313
+ return `${key} = ${value}\n`;
1314
+ });
1315
+ return replaced ? lines : [...lines, `${key} = ${value}\n`];
1316
+ }
1317
+ function canonicalCodexMarketplaceLines(table) {
1318
+ let lines = table?.lines ?? tomlTable(`marketplaces.${H2A_MARKETPLACE_NAME}`, []);
1319
+ lines = setTomlValue({ header: "", lines }, "source_type", '"git"');
1320
+ lines = setTomlValue({ header: "", lines }, "source", `"${H2A_MARKETPLACE_GIT_URL}"`);
1321
+ return setTomlValue({ header: "", lines }, "ref", '"main"');
1322
+ }
1323
+ function isOwnedLegacyCodexMarketplace(table) {
1324
+ const source = tomlStringValue(table, "source");
1325
+ return typeof source === "string" && isOwnedMarketplace(source);
1326
+ }
1327
+ function legacyCodexMarketplaceAuthorization(table) {
1328
+ if (table.opaqueReason) {
1329
+ return { removable: false, unavailable: `legacy table contains an opaque TOML region (${table.opaqueReason})` };
1330
+ }
1331
+ if (table.containsMultilineString) {
1332
+ // This rewriter preserves only the table frame. A legacy table containing
1333
+ // a multiline value is not enough evidence to delete it without a complete
1334
+ // TOML parser, even when its local source appears absent.
1335
+ return { removable: false, unavailable: "legacy table contains a multiline TOML string" };
1336
+ }
1337
+ const sourceType = tomlStringValue(table, "source_type");
1338
+ if (sourceType !== "local")
1339
+ return { removable: false };
1340
+ const source = tomlStringValue(table, "source");
1341
+ if (source === undefined) {
1342
+ return { removable: false, unavailable: "TOML source is not a fully decoded single-line string" };
1343
+ }
1344
+ if (isOwnedMarketplace(source))
1345
+ return { removable: true };
1346
+ try {
1347
+ // statSync follows links, matching the path resolution used by Codex.
1348
+ // An inaccessible path is not evidence that the target is gone.
1349
+ statSync(source);
1350
+ return { removable: false };
1351
+ }
1352
+ catch (error) {
1353
+ const code = error.code;
1354
+ return code === "ENOENT" || code === "ENOTDIR"
1355
+ ? { removable: true }
1356
+ : { removable: false, unavailable: code ?? error.message };
1357
+ }
1358
+ }
1359
+ function redactTomlTableValues(table) {
1360
+ return table.lines
1361
+ .map((line, index) => {
1362
+ if (index === 0)
1363
+ return line.trim();
1364
+ if (line.trim().length === 0)
1365
+ return undefined;
1366
+ const assignment = /^(\s*[^#=\s][^=]*?)=/.exec(line);
1367
+ return assignment ? `${assignment[1].trimEnd()} = <redacted>` : "<redacted>";
1368
+ })
1369
+ .filter((line) => line !== undefined)
1370
+ .join("; ");
1371
+ }
1372
+ function legacyCodexMarketplaceRemoveAction(table) {
1373
+ const marketplace = tomlQuotedName(table.header, "marketplaces");
1374
+ const previous = redactTomlTableValues(table);
1375
+ return `remove Codex legacy marketplace ${marketplace ?? "<missing>"} table (was ${previous})`;
1376
+ }
1377
+ function isEnabledLegacyCodexPlugin(table) {
1378
+ const plugin = tomlQuotedName(table.header, "plugins");
1379
+ return Boolean(plugin && isLegacyH2aPlugin(plugin) && tomlBoolean(table, "enabled") !== false);
1380
+ }
1381
+ function legacyCodexPluginDisableAction(table) {
1382
+ const plugin = tomlQuotedName(table.header, "plugins");
1383
+ return `disable Codex legacy plugin ${plugin ?? "<missing>"} (previous value redacted)`;
1384
+ }
1385
+ function legacyCodexPluginMarketplace(table) {
1386
+ const plugin = tomlQuotedName(table.header, "plugins");
1387
+ const separator = plugin?.lastIndexOf("@");
1388
+ const marketplace = separator === undefined || separator < 0 ? undefined : plugin?.slice(separator + 1);
1389
+ return marketplace && isLegacySentropicName(marketplace) ? marketplace.toLowerCase() : undefined;
1390
+ }
1391
+ function reportLegacyMarketplaceAuthorizationFailure(report, path, marketplace, authorization) {
1392
+ if (!authorization.unavailable) {
1393
+ reportUnverifiedOwnership(report, path, `legacy marketplace ${marketplace}`);
1394
+ return;
1395
+ }
1396
+ report.unrepaired.push(finding("ownership-unverified", `cannot remove legacy marketplace ${marketplace}: its local target cannot be verified absent (${authorization.unavailable}).`, path));
1397
+ }
1398
+ function reportUnverifiedOwnership(report, path, artifact) {
1399
+ report.unrepaired.push(finding("ownership-unverified", `cannot remove ${artifact}: its content does not identify it as an H2A-owned artifact.`, path));
1400
+ }
1401
+ function repairCodexConfig(home, report, options) {
1402
+ const path = codexConfigPath(home);
1403
+ let raw = "";
1404
+ try {
1405
+ raw = existsSync(path) ? readFileSync(path, "utf8") : "";
1406
+ }
1407
+ catch (error) {
1408
+ report.unrepaired.push(finding("config-invalid", `cannot read Codex config for repair: ${error.message}`, path));
1409
+ return;
1410
+ }
1411
+ const parsed = parseTomlTables(raw);
1412
+ if (parsed.unavailable) {
1413
+ report.unrepaired.push(finding("config-invalid", `cannot safely frame Codex TOML for repair: ${parsed.unavailable}`, path));
1414
+ return;
1415
+ }
1416
+ const tables = parsed.tables;
1417
+ const canonicalPlugin = tables.find((table) => tomlQuotedName(table.header, "plugins") === H2A_PLUGIN_SELECTOR);
1418
+ const canonicalMarketplace = tables.find((table) => tomlQuotedName(table.header, "marketplaces") === H2A_MARKETPLACE_NAME);
1419
+ const canonicalMarketplaceOwned = Boolean(canonicalMarketplace && isOwnedCodexMarketplace(canonicalMarketplace, home));
1420
+ const canonicalPluginOwned = Boolean(canonicalPlugin && (canonicalMarketplaceOwned || hasOwnedCanonicalCodexPlugin(home)));
1421
+ if (canonicalMarketplace && !canonicalMarketplaceOwned) {
1422
+ reportUnverifiedOwnership(report, path, `marketplace ${H2A_MARKETPLACE_NAME}`);
1423
+ return;
1424
+ }
1425
+ if (canonicalPlugin && !canonicalPluginOwned) {
1426
+ reportUnverifiedOwnership(report, path, `plugin ${H2A_PLUGIN_SELECTOR}`);
1427
+ return;
1428
+ }
1429
+ const legacyMarketplaceTables = tables.filter((table) => isLegacySentropicName(tomlQuotedName(table.header, "marketplaces")));
1430
+ const marketplaceAuthorizations = new Map(legacyMarketplaceTables.map((table) => [table.header, legacyCodexMarketplaceAuthorization(table)]));
1431
+ const unavailableMarketplace = legacyMarketplaceTables.find((table) => marketplaceAuthorizations.get(table.header)?.unavailable !== undefined);
1432
+ if (unavailableMarketplace) {
1433
+ reportLegacyMarketplaceAuthorizationFailure(report, path, tomlQuotedName(unavailableMarketplace.header, "marketplaces"), marketplaceAuthorizations.get(unavailableMarketplace.header) ?? { removable: false });
1434
+ return;
1435
+ }
1436
+ const legacyMarketplacesToRemove = new Set(legacyMarketplaceTables
1437
+ .filter((table) => marketplaceAuthorizations.get(table.header)?.removable === true)
1438
+ .map((table) => table.header));
1439
+ const authorizedLegacyMarketplaceNames = new Set(legacyMarketplaceTables
1440
+ .filter((table) => legacyMarketplacesToRemove.has(table.header))
1441
+ .map((table) => tomlQuotedName(table.header, "marketplaces")?.toLowerCase())
1442
+ .filter((name) => name !== undefined));
1443
+ // This is a reversible configuration change, not a deletion: once the
1444
+ // canonical replacement is verified by repairCodex, an active legacy plugin
1445
+ // is neutralised only when its own legacy marketplace is authorised too.
1446
+ const legacyPluginsToDisable = new Set(tables
1447
+ .filter(isEnabledLegacyCodexPlugin)
1448
+ .filter((table) => authorizedLegacyMarketplaceNames.has(legacyCodexPluginMarketplace(table) ?? ""))
1449
+ .map((table) => table.header));
1450
+ for (const table of legacyMarketplaceTables) {
1451
+ if (!legacyMarketplacesToRemove.has(table.header)) {
1452
+ reportLegacyMarketplaceAuthorizationFailure(report, path, tomlQuotedName(table.header, "marketplaces"), marketplaceAuthorizations.get(table.header) ?? { removable: false });
1453
+ }
1454
+ }
1455
+ const needsConfigRepair = tables.some((table) => {
1456
+ const marketplace = tomlQuotedName(table.header, "marketplaces");
1457
+ if (isLegacySentropicName(marketplace) && legacyMarketplacesToRemove.has(table.header))
1458
+ return true;
1459
+ if (marketplace === H2A_MARKETPLACE_NAME && !canonicalCodexMarketplaceConfig(table))
1460
+ return true;
1461
+ const plugin = tomlQuotedName(table.header, "plugins");
1462
+ if (plugin && legacyPluginsToDisable.has(table.header))
1463
+ return true;
1464
+ const mcp = tomlQuotedName(table.header, "mcp_servers");
1465
+ return mcp !== undefined && (isDirectH2aMcp(table) || isDirectTrackMcp(table));
1466
+ }) ||
1467
+ !canonicalMarketplace ||
1468
+ tomlBoolean(canonicalPlugin ?? { header: "", lines: [] }, "enabled") !== true;
1469
+ if (!needsConfigRepair)
1470
+ return;
1471
+ const next = rewriteTomlTables(raw, (table) => {
1472
+ const marketplace = tomlQuotedName(table.header, "marketplaces");
1473
+ if (isLegacySentropicName(marketplace) && legacyMarketplacesToRemove.has(table.header))
1474
+ return undefined;
1475
+ if (marketplace === H2A_MARKETPLACE_NAME) {
1476
+ return canonicalCodexMarketplaceConfig(table) ? [table.raw ?? table.lines.join("")] : canonicalCodexMarketplaceLines(table);
1477
+ }
1478
+ const plugin = tomlQuotedName(table.header, "plugins");
1479
+ if (plugin === H2A_PLUGIN_SELECTOR) {
1480
+ return tomlBoolean(table, "enabled") === true ? [table.raw ?? table.lines.join("")] : setTomlValue(table, "enabled", "true");
1481
+ }
1482
+ if (plugin && legacyPluginsToDisable.has(table.header))
1483
+ return setTomlValue(table, "enabled", "false");
1484
+ const mcp = tomlQuotedName(table.header, "mcp_servers");
1485
+ if (mcp !== undefined && (isDirectH2aMcp(table) || isDirectTrackMcp(table)))
1486
+ return undefined;
1487
+ return [table.raw ?? table.lines.join("")];
1488
+ });
1489
+ const additions = [
1490
+ ...(canonicalMarketplace ? [] : tomlTable(`marketplaces.${H2A_MARKETPLACE_NAME}`, [
1491
+ 'source_type = "git"',
1492
+ `source = "${H2A_MARKETPLACE_GIT_URL}"`,
1493
+ 'ref = "main"'
1494
+ ])),
1495
+ ...(canonicalPlugin ? [] : tomlTable(`plugins.\"${H2A_PLUGIN_SELECTOR}\"`, ["enabled = true"]))
1496
+ ];
1497
+ const rendered = additions.length === 0
1498
+ ? next
1499
+ : `${next}${next.endsWith("\n") ? "\n" : "\n\n"}${additions.join("")}`;
1500
+ if (rendered === raw)
1501
+ return;
1502
+ if (options.dryRun) {
1503
+ for (const table of tables.filter((table) => legacyPluginsToDisable.has(table.header))) {
1504
+ planAction(report, legacyCodexPluginDisableAction(table));
1505
+ }
1506
+ for (const table of legacyMarketplaceTables.filter((table) => legacyMarketplacesToRemove.has(table.header))) {
1507
+ planAction(report, legacyCodexMarketplaceRemoveAction(table));
1508
+ }
1509
+ planAction(report, `rewrite ${path}`);
1510
+ return;
1511
+ }
1512
+ const revalidateLegacyMarketplacesBeforeReplace = () => {
1513
+ const revalidatedAuthorizations = new Map(legacyMarketplaceTables.map((table) => [table.header, legacyCodexMarketplaceAuthorization(table)]));
1514
+ const revalidatedUnavailableMarketplace = legacyMarketplaceTables.find((table) => revalidatedAuthorizations.get(table.header)?.unavailable !== undefined);
1515
+ if (revalidatedUnavailableMarketplace) {
1516
+ reportLegacyMarketplaceAuthorizationFailure(report, path, tomlQuotedName(revalidatedUnavailableMarketplace.header, "marketplaces"), revalidatedAuthorizations.get(revalidatedUnavailableMarketplace.header) ?? { removable: false });
1517
+ return "legacy marketplace target became unavailable before atomic rename";
1518
+ }
1519
+ const revalidatedMarketplacesToRemove = new Set(legacyMarketplaceTables
1520
+ .filter((table) => revalidatedAuthorizations.get(table.header)?.removable === true)
1521
+ .map((table) => table.header));
1522
+ if ([...legacyMarketplacesToRemove].some((header) => !revalidatedMarketplacesToRemove.has(header)) ||
1523
+ [...revalidatedMarketplacesToRemove].some((header) => !legacyMarketplacesToRemove.has(header))) {
1524
+ for (const table of legacyMarketplaceTables) {
1525
+ const initial = marketplaceAuthorizations.get(table.header);
1526
+ const revalidated = revalidatedAuthorizations.get(table.header);
1527
+ if (initial?.removable === true && revalidated?.removable !== true) {
1528
+ reportLegacyMarketplaceAuthorizationFailure(report, path, tomlQuotedName(table.header, "marketplaces"), revalidated ?? { removable: false });
1529
+ }
1530
+ }
1531
+ return "legacy marketplace authorization changed before atomic rename";
1532
+ }
1533
+ return undefined;
1534
+ };
1535
+ for (const table of tables.filter((table) => legacyPluginsToDisable.has(table.header))) {
1536
+ planAction(report, legacyCodexPluginDisableAction(table));
1537
+ }
1538
+ for (const table of legacyMarketplaceTables.filter((table) => legacyMarketplacesToRemove.has(table.header))) {
1539
+ planAction(report, legacyCodexMarketplaceRemoveAction(table));
1540
+ }
1541
+ try {
1542
+ const error = atomicConfigurationWrite(path, raw, rendered, "toml", options.testConfigurationWrite, revalidateLegacyMarketplacesBeforeReplace);
1543
+ if (error)
1544
+ throw new Error(error);
1545
+ pushUnique(report.changed, path);
1546
+ }
1547
+ catch (error) {
1548
+ report.unrepaired.push(finding("config-invalid", `cannot write Codex config: ${error.message}`, path));
1549
+ }
1550
+ }
1551
+ function repairClaudeMcpConfigs(home, report, dryRun, testConfigurationWrite) {
1552
+ let failed = false;
1553
+ for (const path of claudeConfigPaths(home)) {
1554
+ let raw;
1555
+ try {
1556
+ raw = readFileSync(path, "utf8");
1557
+ const value = JSON.parse(raw);
1558
+ if (!isPlainObject(value))
1559
+ throw new Error("JSON root is not an object");
1560
+ }
1561
+ catch (error) {
1562
+ report.unrepaired.push(finding("config-invalid", `cannot parse Claude MCP config for repair: ${error.message}`, path));
1563
+ failed = true;
1564
+ continue;
1565
+ }
1566
+ const rendered = removeOwnedJsonMcpEntries(raw);
1567
+ if (rendered === undefined) {
1568
+ report.unrepaired.push(finding("config-invalid", "cannot locate Claude MCP entries for safe repair", path));
1569
+ failed = true;
1570
+ continue;
1571
+ }
1572
+ if (rendered === raw)
1573
+ continue;
1574
+ if (dryRun) {
1575
+ planAction(report, `rewrite ${path}`);
1576
+ continue;
1577
+ }
1578
+ try {
1579
+ const error = atomicConfigurationWrite(path, raw, rendered, "json", testConfigurationWrite);
1580
+ if (error)
1581
+ throw new Error(error);
1582
+ pushUnique(report.changed, path);
1583
+ }
1584
+ catch (error) {
1585
+ failed = true;
1586
+ report.unrepaired.push(finding("config-invalid", `cannot write Claude MCP config: ${error.message}`, path));
1587
+ }
1588
+ }
1589
+ return failed;
1590
+ }
1591
+ function repairClaudeSettingsMarketplaces(home, report, dryRun, testConfigurationWrite) {
1592
+ const path = claudeSettingsPath(home);
1593
+ if (!existsSync(path))
1594
+ return false;
1595
+ let raw;
1596
+ try {
1597
+ raw = readFileSync(path, "utf8");
1598
+ if (!isPlainObject(JSON.parse(raw)))
1599
+ throw new Error("JSON root is not an object");
1600
+ }
1601
+ catch (error) {
1602
+ report.unrepaired.push(finding("config-invalid", `cannot parse Claude settings for repair: ${error.message}`, path));
1603
+ return true;
1604
+ }
1605
+ const result = removeOwnedClaudeMarketplaceEntries(raw);
1606
+ if (!result) {
1607
+ report.unrepaired.push(finding("config-invalid", "cannot locate Claude settings marketplaces for safe repair", path));
1608
+ return true;
1609
+ }
1610
+ for (const marketplace of result.unverified) {
1611
+ reportUnverifiedOwnership(report, path, `legacy marketplace ${marketplace}`);
1612
+ }
1613
+ if (result.rendered === raw)
1614
+ return false;
1615
+ if (dryRun) {
1616
+ planAction(report, `rewrite ${path}`);
1617
+ return false;
1618
+ }
1619
+ try {
1620
+ const error = atomicConfigurationWrite(path, raw, result.rendered, "json", testConfigurationWrite);
1621
+ if (error)
1622
+ throw new Error(error);
1623
+ pushUnique(report.changed, path);
1624
+ return false;
1625
+ }
1626
+ catch (error) {
1627
+ report.unrepaired.push(finding("config-invalid", `cannot write Claude settings: ${error.message}`, path));
1628
+ return true;
1629
+ }
1630
+ }
1631
+ // runCommand contains the installation doctor's only native runner invocation.
1632
+ // Claude's native uninstall argv is exactly ["plugin", "uninstall", selector], with no preceding flags.
1633
+ // Codex has no native uninstall path; its repair rewrites its own configuration and cache.
1634
+ function runCommand(report, runner, command, args, dryRun, nativeCliReachable) {
1635
+ if (command === "claude" &&
1636
+ args[0] === "plugin" &&
1637
+ args[1] === "uninstall" &&
1638
+ !isAuthorizedClaudePluginUninstall(args[2])) {
1639
+ report.unrepaired.push(finding("host-command-refused", `refused native command: ${command} ${args.join(" ")}; selector ${args[2] ?? "<missing>"} is not authorized for uninstall.`));
1640
+ return false;
1641
+ }
1642
+ if (!nativeCliReachable) {
1643
+ report.unrepaired.push(finding("host-command-unavailable", `cannot run ${command} ${args.join(" ")}: the native ${command} CLI is unavailable.`));
1644
+ return false;
1645
+ }
1646
+ if (dryRun) {
1647
+ planAction(report, `${command} ${args.join(" ")}`);
1648
+ return true;
1649
+ }
1650
+ const result = runner(command, args);
1651
+ if (result.ok)
1652
+ return true;
1653
+ report.unrepaired.push(finding("host-command-failed", `${command} ${args.join(" ")} failed: ${result.message ?? "unknown error"}`));
1654
+ return false;
1655
+ }
1656
+ /**
1657
+ * A TOML region that doctor deliberately leaves alone is only informational if
1658
+ * Codex can still load the resulting configuration. Its parser is a framing
1659
+ * safeguard, not a second implementation of Codex's configuration grammar;
1660
+ * `codex mcp list` is therefore the acceptance oracle.
1661
+ */
1662
+ function codexOracleContext(codexHome) {
1663
+ return {
1664
+ cwd: codexHome,
1665
+ // This only changes the spawned Codex process. It never reads or writes
1666
+ // the owner's CODEX_HOME while checking the disposable known-good root.
1667
+ env: { ...process.env, CODEX_HOME: codexHome }
1668
+ };
1669
+ }
1670
+ function verifyCodexOracleKnownGood(testOracle) {
1671
+ if (testOracle)
1672
+ return testOracle.knownGood;
1673
+ let oracleHome;
1674
+ let oracleResult;
1675
+ try {
1676
+ oracleHome = mkdtempSync(join(tmpdir(), "h2a-codex-config-oracle-"));
1677
+ writeFileSync(join(oracleHome, "config.toml"), "# known-good empty Codex configuration\n");
1678
+ oracleResult = defaultHostCommand("codex", ["mcp", "list"], codexOracleContext(oracleHome));
1679
+ }
1680
+ catch (error) {
1681
+ oracleResult = { ok: false, message: error.message };
1682
+ }
1683
+ if (oracleHome) {
1684
+ try {
1685
+ rmSync(oracleHome, { recursive: true, force: true });
1686
+ }
1687
+ catch (error) {
1688
+ return { ok: false, message: `cannot remove disposable oracle root: ${error.message}` };
1689
+ }
1690
+ }
1691
+ return oracleResult;
1692
+ }
1693
+ function verifyCodexConfiguration(home, report, nativeCliReachable, testOracle) {
1694
+ if (!nativeCliReachable)
1695
+ return false;
1696
+ const oracleSelfTest = verifyCodexOracleKnownGood(testOracle);
1697
+ const configPath = codexConfigPath(home);
1698
+ if (!oracleSelfTest.ok) {
1699
+ report.unrepaired.push(finding("host-config-unverifiable", `Codex configuration oracle could not validate a known-good minimal configuration; it did not judge ${configPath}: ${oracleSelfTest.message ?? "unknown error"}`, configPath));
1700
+ return false;
1701
+ }
1702
+ const result = testOracle?.candidate ?? defaultHostCommand("codex", ["mcp", "list"], codexOracleContext(codexRoot(home)));
1703
+ if (result.ok)
1704
+ return true;
1705
+ // A region is deliberately preserved only when the host accepts the whole
1706
+ // file containing it. Once the oracle rejects that file, retaining any
1707
+ // config-preserved finding for it would recreate the false-clean verdict.
1708
+ report.preserved = report.preserved.filter((entry) => !(entry.code === "config-preserved" && entry.path === configPath));
1709
+ report.unrepaired.push(finding("config-invalid", `Codex rejected its configuration while running codex mcp list: ${result.message ?? "unknown error"}`, configPath));
1710
+ return false;
1711
+ }
1712
+ function reportAcceptedOpaqueCodexRegions(home, report) {
1713
+ const path = codexConfigPath(home);
1714
+ try {
1715
+ const raw = readFileSync(path, "utf8");
1716
+ const parsed = parseTomlTables(raw);
1717
+ if (!parsed.unavailable)
1718
+ reportOpaqueTomlRegions(report, path, parsed.tables);
1719
+ }
1720
+ catch {
1721
+ // inspectCodex carries a configuration that cannot be read or framed.
1722
+ }
1723
+ }
1724
+ function artifactSnapshots(paths) {
1725
+ const snapshots = new Map();
1726
+ for (const path of paths) {
1727
+ try {
1728
+ const metadata = statSync(path);
1729
+ snapshots.set(path, `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeMs}`);
1730
+ }
1731
+ catch {
1732
+ // The host inspection already carries an unavailable load-bearing artifact.
1733
+ }
1734
+ }
1735
+ return snapshots;
1736
+ }
1737
+ function actualRepairPaths(before, after, beforeArtifacts) {
1738
+ const rewrittenArtifacts = after.coherencePaths.filter((path) => {
1739
+ try {
1740
+ const metadata = statSync(path);
1741
+ const current = `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeMs}`;
1742
+ return beforeArtifacts.get(path) !== current;
1743
+ }
1744
+ catch {
1745
+ return false;
1746
+ }
1747
+ });
1748
+ return [...before.changed, ...rewrittenArtifacts];
1749
+ }
1750
+ function removeOwnedCache(report, path, artifact, owned, dryRun) {
1751
+ if (!owned) {
1752
+ reportUnverifiedOwnership(report, path, artifact);
1753
+ return;
1754
+ }
1755
+ if (dryRun) {
1756
+ planAction(report, `remove ${path}`);
1757
+ return;
1758
+ }
1759
+ try {
1760
+ rmSync(path, { recursive: true, force: true });
1761
+ pushUnique(report.changed, path);
1762
+ }
1763
+ catch (error) {
1764
+ report.unrepaired.push(finding("orphan-cache", `cannot remove ${path}: ${error.message}`, path));
1765
+ }
1766
+ }
1767
+ function codexReplacementIsReady(home, version) {
1768
+ return (cacheVersionMatches(join(codexRoot(home), "plugins", "cache", H2A_MARKETPLACE_NAME, "h2a", version), version) &&
1769
+ isOwnedMarketplace(join(codexRoot(home), ".tmp", "marketplaces", H2A_MARKETPLACE_NAME)));
1770
+ }
1771
+ function claudeKnownMarketplaceIsOwned(value) {
1772
+ return isPlainObject(value) && typeof value.installLocation === "string" && isOwnedMarketplace(value.installLocation);
1773
+ }
1774
+ function claudeSettingsMarketplaceIsOwned(value) {
1775
+ if (!isPlainObject(value))
1776
+ return false;
1777
+ const source = value.source;
1778
+ return (isPlainObject(source) &&
1779
+ source.source === "directory" &&
1780
+ typeof source.path === "string" &&
1781
+ isOwnedMarketplace(source.path));
1782
+ }
1783
+ function claudeReplacementIsReady(home, version) {
1784
+ const known = readJson(join(claudeRoot(home), "plugins", "known_marketplaces.json")).value;
1785
+ const installed = readJson(join(claudeRoot(home), "plugins", "installed_plugins.json")).value;
1786
+ const plugins = isPlainObject(installed?.plugins) ? installed.plugins : {};
1787
+ return (canonicalMarketplace(known?.[H2A_MARKETPLACE_NAME]) &&
1788
+ h2aPluginEntries(plugins[H2A_PLUGIN_SELECTOR]).some((entry) => pluginEntryIsCurrent(entry, version)));
1789
+ }
1790
+ function repairCodex(home, version, runner, writeMarker, dryRun, testConfigurationWrite, nativeCliReachable = true) {
1791
+ const before = inspectCodex(home, version);
1792
+ const beforeArtifacts = artifactSnapshots(before.coherencePaths);
1793
+ // A vanished local marketplace is a distinct recovery case. Codex reports
1794
+ // `plugin marketplace upgrade` success with "No configured Git marketplaces
1795
+ // to upgrade", leaving the dead source and its cached plugin untouched.
1796
+ // Install the native replacement first, and leave every old configuration
1797
+ // and endpoint intact until that replacement is verified.
1798
+ let sourceRecovery = true;
1799
+ try {
1800
+ const config = existsSync(codexConfigPath(home)) ? readFileSync(codexConfigPath(home), "utf8") : "";
1801
+ const parsed = parseTomlTables(config);
1802
+ const canonicalTable = parsed.tables.find((table) => tomlQuotedName(table.header, "marketplaces") === H2A_MARKETPLACE_NAME);
1803
+ sourceRecovery = !parsed.unavailable && !canonicalCodexMarketplaceConfig(canonicalTable);
1804
+ }
1805
+ catch {
1806
+ // repairCodexConfig below records the unreadable config as unrepaired.
1807
+ }
1808
+ const needsPlugin = before.findings.some((entry) => ["marketplace-missing", "marketplace-stale", "plugin-missing", "plugin-stale", "version-skew"].includes(entry.code));
1809
+ let replacementCommandsSucceeded = true;
1810
+ if (sourceRecovery) {
1811
+ replacementCommandsSucceeded = runCommand(before, runner, "codex", [
1812
+ "plugin",
1813
+ "marketplace",
1814
+ "add",
1815
+ H2A_MARKETPLACE_REPOSITORY,
1816
+ "--ref",
1817
+ "main"
1818
+ ], dryRun, nativeCliReachable);
1819
+ }
1820
+ else if (needsPlugin) {
1821
+ replacementCommandsSucceeded = runCommand(before, runner, "codex", ["plugin", "marketplace", "upgrade"], dryRun, nativeCliReachable);
1822
+ }
1823
+ if (needsPlugin && replacementCommandsSucceeded) {
1824
+ replacementCommandsSucceeded = runCommand(before, runner, "codex", ["plugin", "add", H2A_PLUGIN_SELECTOR], dryRun, nativeCliReachable);
1825
+ }
1826
+ const replacementReady = dryRun
1827
+ ? replacementCommandsSucceeded
1828
+ : replacementCommandsSucceeded && codexReplacementIsReady(home, version);
1829
+ if (!replacementReady) {
1830
+ if (replacementCommandsSucceeded && !dryRun) {
1831
+ before.unrepaired.push(finding("plugin-missing", "cannot safely remove existing Codex endpoints because the canonical replacement was not verified."));
1832
+ }
1833
+ return combineAfterRepair(before, inspectCodex(home, version));
1834
+ }
1835
+ repairCodexConfig(home, before, { dryRun, testConfigurationWrite });
1836
+ if (before.unrepaired.some((entry) => entry.code === "config-invalid")) {
1837
+ return combineAfterRepair(before, inspectCodex(home, version));
1838
+ }
1839
+ // v1 deliberately retains legacy orphan caches. A stale version underneath
1840
+ // the canonical, verified cache remains part of the normal version repair.
1841
+ const cache = join(codexRoot(home), "plugins", "cache");
1842
+ if (cacheVersionMatches(join(cache, H2A_MARKETPLACE_NAME, "h2a", version), version)) {
1843
+ for (const stale of codexCacheVersions(home).filter((entry) => entry !== version && isOwnedPlugin(join(cache, H2A_MARKETPLACE_NAME, "h2a", entry)))) {
1844
+ const path = join(cache, H2A_MARKETPLACE_NAME, "h2a", stale);
1845
+ removeOwnedCache(before, path, `Codex cache ${path}`, isOwnedPlugin(path), dryRun);
1846
+ }
1847
+ }
1848
+ const afterNativeRepair = inspectCodex(home, version);
1849
+ if (dryRun) {
1850
+ if (before.plannedActions.length > 0)
1851
+ planAction(before, `record repair marker after verified mutation at ${repairMarkerPath(home, "codex")}`);
1852
+ }
1853
+ else {
1854
+ const repairedPaths = actualRepairPaths(before, afterNativeRepair, beforeArtifacts);
1855
+ recordRepairMarker(home, before, repairedPaths, writeMarker);
1856
+ }
1857
+ return combineAfterRepair(before, inspectCodex(home, version));
1858
+ }
1859
+ function repairClaude(home, version, runner, writeMarker, dryRun, testClaudePluginUninstalls, testConfigurationWrite, nativeCliReachable = true) {
1860
+ const before = inspectClaude(home, version);
1861
+ const beforeArtifacts = artifactSnapshots(before.coherencePaths);
1862
+ const knownPath = join(claudeRoot(home), "plugins", "known_marketplaces.json");
1863
+ const installedPath = join(claudeRoot(home), "plugins", "installed_plugins.json");
1864
+ const known = readJson(knownPath).value ?? {};
1865
+ const installed = readJson(installedPath).value ?? {};
1866
+ const initialPlugins = isPlainObject(installed.plugins) ? installed.plugins : {};
1867
+ const enabledPlugins = claudeEnabledPlugins(readJson(claudeSettingsPath(home)).value);
1868
+ const needsMarketplace = before.findings.some((entry) => ["marketplace-missing", "marketplace-stale"].includes(entry.code));
1869
+ const needsPlugin = before.findings.some((entry) => ["plugin-missing", "version-skew", "plugin-stale"].includes(entry.code));
1870
+ let replacementCommandsSucceeded = true;
1871
+ if (needsMarketplace) {
1872
+ replacementCommandsSucceeded = runCommand(before, runner, "claude", ["plugin", "marketplace", "add", H2A_MARKETPLACE_REPOSITORY], dryRun, nativeCliReachable);
1873
+ }
1874
+ const canonical = h2aPluginEntries(initialPlugins[H2A_PLUGIN_SELECTOR]);
1875
+ const action = canonical.length === 0 ? "install" : "update";
1876
+ if (needsPlugin && replacementCommandsSucceeded) {
1877
+ replacementCommandsSucceeded = runCommand(before, runner, "claude", ["plugin", action, H2A_PLUGIN_SELECTOR], dryRun, nativeCliReachable);
1878
+ }
1879
+ const replacementReady = dryRun
1880
+ ? replacementCommandsSucceeded
1881
+ : replacementCommandsSucceeded && claudeReplacementIsReady(home, version);
1882
+ if (!replacementReady) {
1883
+ if (replacementCommandsSucceeded && !dryRun) {
1884
+ before.unrepaired.push(finding("plugin-missing", "cannot safely remove existing Claude endpoints because the canonical replacement was not verified."));
1885
+ }
1886
+ return combineAfterRepair(before, inspectClaude(home, version));
1887
+ }
1888
+ if (repairClaudeMcpConfigs(home, before, dryRun, testConfigurationWrite) ||
1889
+ repairClaudeSettingsMarketplaces(home, before, dryRun, testConfigurationWrite)) {
1890
+ return combineAfterRepair(before, inspectClaude(home, version));
1891
+ }
1892
+ const repairedKnown = readJson(knownPath).value ?? {};
1893
+ const repairedInstalled = readJson(installedPath).value ?? {};
1894
+ for (const marketplace of Object.keys(repairedKnown).filter(isLegacySentropicName)) {
1895
+ if (!claudeKnownMarketplaceIsOwned(repairedKnown[marketplace])) {
1896
+ reportUnverifiedOwnership(before, knownPath, `legacy marketplace ${marketplace}`);
1897
+ continue;
1898
+ }
1899
+ runCommand(before, runner, "claude", ["plugin", "marketplace", "remove", marketplace], dryRun, nativeCliReachable);
1900
+ }
1901
+ const plugins = isPlainObject(repairedInstalled.plugins) ? repairedInstalled.plugins : {};
1902
+ for (const plugin of Object.keys(plugins).filter((name) => (isLegacyH2aPlugin(name) || /^track@sentropic$/i.test(name)) &&
1903
+ enabledPlugins[name] !== false)) {
1904
+ if (!pluginEntriesAreOwned(plugin, plugins[plugin])) {
1905
+ reportUnverifiedOwnership(before, installedPath, `legacy plugin ${plugin}`);
1906
+ continue;
1907
+ }
1908
+ runCommand(before, runner, "claude", ["plugin", "uninstall", plugin], dryRun, nativeCliReachable);
1909
+ }
1910
+ for (const plugin of testClaudePluginUninstalls) {
1911
+ runCommand(before, runner, "claude", ["plugin", "uninstall", plugin], dryRun, nativeCliReachable);
1912
+ }
1913
+ // v1 deliberately retains legacy orphan caches. A stale version underneath
1914
+ // the canonical, verified cache remains part of the normal version repair.
1915
+ const cache = join(claudeRoot(home), "plugins", "cache");
1916
+ if (cacheVersionMatches(join(cache, H2A_MARKETPLACE_NAME, "h2a", version), version)) {
1917
+ for (const stale of claudeCacheVersions(home).filter((entry) => entry !== version && isOwnedPlugin(join(cache, H2A_MARKETPLACE_NAME, "h2a", entry)))) {
1918
+ const path = join(cache, H2A_MARKETPLACE_NAME, "h2a", stale);
1919
+ removeOwnedCache(before, path, `Claude cache ${path}`, isOwnedPlugin(path), dryRun);
1920
+ }
1921
+ }
1922
+ const afterNativeRepair = inspectClaude(home, version);
1923
+ if (dryRun) {
1924
+ if (before.plannedActions.length > 0)
1925
+ planAction(before, `record repair marker after verified mutation at ${repairMarkerPath(home, "claude")}`);
1926
+ }
1927
+ else {
1928
+ recordRepairMarker(home, before, actualRepairPaths(before, afterNativeRepair, beforeArtifacts), writeMarker);
1929
+ }
1930
+ return combineAfterRepair(before, inspectClaude(home, version));
1931
+ }
1932
+ function combineAfterRepair(before, after) {
1933
+ const unrepaired = [...before.unrepaired, ...after.findings].filter(isBlockingFinding);
1934
+ return {
1935
+ ...after,
1936
+ changed: before.changed,
1937
+ preserved: [...before.preserved, ...after.preserved],
1938
+ unrepaired,
1939
+ coherencePaths: [...new Set([...before.coherencePaths, ...after.coherencePaths])],
1940
+ plannedActions: [...new Set([...before.plannedActions, ...after.plannedActions])],
1941
+ repairMarker: after.repairMarker ?? before.repairMarker
1942
+ };
1943
+ }
1944
+ function freezeReport(report) {
1945
+ const outcomes = uniqueFindings([
1946
+ ...report.preserved,
1947
+ ...report.unrepaired,
1948
+ ...report.findings,
1949
+ ...report.diagnostics
1950
+ ]);
1951
+ const preserved = outcomes.filter((entry) => hostFindingBucket(entry) === "preserved");
1952
+ const failures = outcomes.filter((entry) => hostFindingBucket(entry) === "failure");
1953
+ const unverifiable = outcomes.filter((entry) => hostFindingBucket(entry) === "unverifiable");
1954
+ return {
1955
+ host: report.host,
1956
+ ok: failures.length === 0 && unverifiable.length === 0,
1957
+ findings: report.findings,
1958
+ diagnostics: report.diagnostics,
1959
+ changed: report.changed,
1960
+ preserved,
1961
+ failures,
1962
+ unverifiable,
1963
+ unrepaired: [...failures, ...unverifiable],
1964
+ coherencePaths: report.coherencePaths,
1965
+ plannedActions: report.plannedActions,
1966
+ repairMarkerPath: report.repairMarkerPath,
1967
+ ...(report.repairMarker ? { repairMarker: report.repairMarker } : {})
1968
+ };
1969
+ }
1970
+ /** Inspect, and optionally repair, the H2A installation state of both supported hosts. */
1971
+ export function doctorHostInstallations(options = {}) {
1972
+ const home = options.home ?? homedir();
1973
+ const version = options.version ?? currentCliVersion();
1974
+ const repair = options.repair === true;
1975
+ const dryRun = options.dryRun === true;
1976
+ const runner = options.runHostCommand ?? defaultHostCommand;
1977
+ const writeMarker = options.writeRepairMarker ?? ((path, content) => writeFileSync(path, content));
1978
+ const inspectOrRepair = (host) => {
1979
+ const artifacts = hostConfigurationArtifacts(home, host);
1980
+ const cli = hostCliCanRunNativeRepair(host, options.runHostCommand !== undefined, options.testHostCliReachable);
1981
+ if (artifacts.unavailable.length > 0) {
1982
+ return unavailableHostConfigurationReport(home, host, artifacts.unavailable);
1983
+ }
1984
+ if (cli.state === "unavailable") {
1985
+ return unavailableHostCliReport(home, host, cli);
1986
+ }
1987
+ const cliReachable = cli.state === "reachable";
1988
+ if (!cliReachable && artifacts.present.length === 0) {
1989
+ return absentHostReport(home, host);
1990
+ }
1991
+ let report;
1992
+ if (host === "claude") {
1993
+ report = repair
1994
+ ? repairClaude(home, version, runner, writeMarker, dryRun, options.testClaudePluginUninstalls ?? [], options.testConfigurationWrite, cliReachable)
1995
+ : inspectClaude(home, version);
1996
+ }
1997
+ else {
1998
+ report = repair
1999
+ ? repairCodex(home, version, runner, writeMarker, dryRun, options.testConfigurationWrite, cliReachable)
2000
+ : inspectCodex(home, version);
2001
+ // `testHostCliReachable` deliberately models only CLI presence for
2002
+ // hermetic fixture tests. It must not accidentally query a developer's
2003
+ // configured Codex installation; the production path has no such
2004
+ // override and always asks the real host.
2005
+ if (cliReachable && (options.testHostCliReachable === undefined || options.testCodexConfigurationOracle !== undefined)) {
2006
+ const accepted = verifyCodexConfiguration(home, report, cliReachable, options.testCodexConfigurationOracle);
2007
+ if (accepted)
2008
+ reportAcceptedOpaqueCodexRegions(home, report);
2009
+ }
2010
+ }
2011
+ if (!cliReachable)
2012
+ report.unrepaired.push(unreachableHostCliDiagnostic(host, artifacts.present));
2013
+ return report;
2014
+ };
2015
+ const mutable = [inspectOrRepair("claude"), inspectOrRepair("codex")];
2016
+ const hosts = mutable.map(freezeReport);
2017
+ return {
2018
+ ok: hosts.every((host) => host.ok),
2019
+ repair,
2020
+ dryRun,
2021
+ sessionFreshnessGuarantee: HOST_REPAIR_FRESHNESS_GUARANTEE,
2022
+ nativeCommandFailureLimit: HOST_REPAIR_NATIVE_COMMAND_FAILURE_LIMIT,
2023
+ version,
2024
+ hosts
2025
+ };
2026
+ }
2027
+ /**
2028
+ * A host repair cannot rewire an already-open stdio connection. A recorded
2029
+ * repair marker is authoritative for our repairs. Doctor does not infer the
2030
+ * complete runtime load graph, so externally modified artifacts are diagnostic
2031
+ * only and never become a live-session freshness verdict.
2032
+ */
2033
+ export function findLiveSessionsPredatingHostConfig(sessions, hosts) {
2034
+ const byHost = new Map(hosts.map((host) => [host.host, host]));
2035
+ const findings = [];
2036
+ for (const session of sessions) {
2037
+ const host = session.host === "claude" || session.host === "codex" ? session.host : undefined;
2038
+ if (!host)
2039
+ continue;
2040
+ const report = byHost.get(host);
2041
+ if (!report)
2042
+ continue;
2043
+ const startedAt = Date.parse(session.startedAt);
2044
+ if (!Number.isFinite(startedAt)) {
2045
+ findings.push({
2046
+ host,
2047
+ sessionId: session.sessionId,
2048
+ startedAt: session.startedAt,
2049
+ configPath: report.repairMarkerPath,
2050
+ message: `cannot verify when live ${host} session ${session.sessionId} started; ` +
2051
+ "its temporal order relative to host repairs is unknown and it must be restarted."
2052
+ });
2053
+ continue;
2054
+ }
2055
+ const marker = report.repairMarker;
2056
+ if (!marker) {
2057
+ const markerState = existsSync(report.repairMarkerPath) ? "unavailable" : "missing";
2058
+ findings.push({
2059
+ host,
2060
+ sessionId: session.sessionId,
2061
+ startedAt: session.startedAt,
2062
+ configPath: report.repairMarkerPath,
2063
+ message: `cannot verify host repair marker ${report.repairMarkerPath}: it is ${markerState}; ` +
2064
+ `live ${host} session ${session.sessionId} must be restarted.`
2065
+ });
2066
+ continue;
2067
+ }
2068
+ const repairedAt = Date.parse(marker.repairedAt);
2069
+ if (!Number.isFinite(repairedAt)) {
2070
+ findings.push({
2071
+ host,
2072
+ sessionId: session.sessionId,
2073
+ startedAt: session.startedAt,
2074
+ configPath: marker.path,
2075
+ message: `cannot verify host repair marker ${marker.path}; live ${host} session ${session.sessionId} must be restarted.`
2076
+ });
2077
+ continue;
2078
+ }
2079
+ if (startedAt < repairedAt) {
2080
+ findings.push({
2081
+ host,
2082
+ sessionId: session.sessionId,
2083
+ startedAt: session.startedAt,
2084
+ configPath: marker.path,
2085
+ message: `live ${host} session ${session.sessionId} started before repair marker ${marker.path}; ` +
2086
+ "it may still run the pre-repair H2A/Track MCP set and must be restarted."
2087
+ });
2088
+ continue;
2089
+ }
2090
+ }
2091
+ return findings;
2092
+ }
2093
+ //# sourceMappingURL=installation-doctor.js.map