kimi-plugin-cc 2.5.4 → 2.5.5

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.
@@ -26,6 +26,12 @@
26
26
  import { parse as parseToml } from "../vendor/smol-toml/parse.js";
27
27
  import { classifyHookCommandDrift, describeHookCommandDrift, hostIdFromHookCommand, isOurApprovalHookCommand, } from "./install-paths.js";
28
28
  const MARKER_TAG = "kimi-plugin-cc-managed";
29
+ /**
30
+ * Plain install removes unpaired marker comments for every host and leaves each
31
+ * host's hook table in place. A scoped uninstall removes only this host's
32
+ * markers, so it would disarm a healthy block and keep another host's orphan.
33
+ */
34
+ export const ORPHAN_MARKER_REMEDY = "Run /kimi:setup (without --uninstall or --check) to repair: install removes orphaned managed marker comments for every host and keeps each host's hook table. A scoped --uninstall does not remove another host's orphan marker. Another tool rewriting ~/.kimi-code/config.toml usually causes this.";
29
35
  /**
30
36
  * Strict BEGIN matcher. Accepts `# === BEGIN kimi-plugin-cc-managed`
31
37
  * with an optional `:<host-id>` suffix, an optional ` (vX.Y.Z)` suffix,
@@ -135,40 +141,41 @@ function findMarkers(lines) {
135
141
  return hits;
136
142
  }
137
143
  /**
138
- * Pair BEGIN/END markers into well-formed blocks. Return the first orphan
139
- * (BEGIN-without-END or END-without-BEGIN); verification fails closed on either.
144
+ * Pair BEGIN/END markers into well-formed blocks. A block is a BEGIN followed
145
+ * immediately (in marker order) by an END with the same host suffix; every
146
+ * other marker is an orphan. Nested or interleaved markers therefore orphan
147
+ * both sides rather than being guessed into a block. Callers decide which
148
+ * orphans fail closed (see `parseManagedBlock`).
140
149
  */
141
150
  function pairBlocks(lines) {
142
151
  const markers = findMarkers(lines);
143
152
  const blocks = [];
144
- let open = null;
145
- for (const marker of markers) {
146
- if (marker.kind === "begin") {
147
- // A second BEGIN before the previous one closed → the previous is orphaned.
148
- if (open !== null)
149
- return { orphan: "BEGIN-without-END" };
150
- open = marker;
153
+ const orphans = [];
154
+ let i = 0;
155
+ while (i < markers.length) {
156
+ const marker = markers[i];
157
+ const next = markers[i + 1];
158
+ if (marker.kind === "begin" && next !== undefined && next.kind === "end" && next.hostId === marker.hostId) {
159
+ const body = validateBlockBody(lines.slice(marker.index + 1, next.index));
160
+ blocks.push({
161
+ hostId: marker.hostId,
162
+ beginLine: marker.index,
163
+ endLine: next.index,
164
+ commandPath: body.commandPath,
165
+ valid: body.valid,
166
+ invalidReason: body.invalidReason,
167
+ });
168
+ i += 2;
151
169
  continue;
152
170
  }
153
- // end
154
- if (open === null)
155
- return { orphan: "END-without-BEGIN" };
156
- if (open.hostId !== marker.hostId)
157
- return { orphan: "BEGIN-without-END" };
158
- const body = validateBlockBody(lines.slice(open.index + 1, marker.index));
159
- blocks.push({
160
- hostId: open.hostId,
161
- beginLine: open.index,
162
- endLine: marker.index,
163
- commandPath: body.commandPath,
164
- valid: body.valid,
165
- invalidReason: body.invalidReason,
171
+ orphans.push({
172
+ line: marker.index,
173
+ detail: marker.kind === "begin" ? "BEGIN-without-END" : "END-without-BEGIN",
174
+ hostId: marker.hostId,
166
175
  });
167
- open = null;
176
+ i += 1;
168
177
  }
169
- if (open !== null)
170
- return { orphan: "BEGIN-without-END" };
171
- return { blocks };
178
+ return { blocks, orphans };
172
179
  }
173
180
  /** Validate the lines BETWEEN a BEGIN/END pair against the safety grammar. */
174
181
  function validateBlockBody(rawBody) {
@@ -274,24 +281,30 @@ function validateBlockBody(rawBody) {
274
281
  export function parseManagedBlock(contents, hostId) {
275
282
  const rawLines = contents.split("\n");
276
283
  const lines = rawLines.map((line) => line.replace(/\r$/, ""));
277
- const paired = pairBlocks(lines);
278
- if ("orphan" in paired) {
279
- return { state: { kind: "orphan", detail: paired.orphan }, blocks: [], lines };
284
+ const { blocks, orphans } = pairBlocks(lines);
285
+ // An orphan suffixed for ANOTHER host cannot be part of this host's block:
286
+ // this host's verdict still rests on its own well-formed pair, the whole-file
287
+ // hook-set validation, and the real-parser cleanliness check. Legacy and own
288
+ // orphans stay fail-closed because they may be this host's damaged block.
289
+ const ownOrphan = orphans.find((o) => o.hostId === null || o.hostId === hostId);
290
+ const foreignOrphans = orphans.filter((o) => o.hostId !== null && o.hostId !== hostId);
291
+ if (ownOrphan !== undefined) {
292
+ return { state: { kind: "orphan", detail: ownOrphan.detail }, blocks: [], lines, foreignOrphans };
280
293
  }
281
- const blocks = paired.blocks;
282
294
  // A block belongs to this host when its marker suffix matches or, for a
283
295
  // legacy un-suffixed block, its command derives to this host. An unattributable
284
296
  // legacy command is claimable by the next installer; a command attributed to
285
297
  // another host is ignored so its block remains untouched.
286
298
  const relevant = blocks.filter((b) => effectiveHost(b, hostId) === hostId);
287
299
  if (relevant.length === 0) {
288
- return { state: { kind: "absent" }, blocks, lines };
300
+ return { state: { kind: "absent" }, blocks, lines, foreignOrphans };
289
301
  }
290
302
  if (relevant.length > 1) {
291
303
  return {
292
304
  state: { kind: "duplicate", beginLines: relevant.map((b) => b.beginLine) },
293
305
  blocks,
294
306
  lines,
307
+ foreignOrphans,
295
308
  };
296
309
  }
297
310
  const block = relevant[0];
@@ -307,6 +320,7 @@ export function parseManagedBlock(contents, hostId) {
307
320
  },
308
321
  blocks,
309
322
  lines,
323
+ foreignOrphans,
310
324
  };
311
325
  }
312
326
  /**
@@ -335,27 +349,6 @@ export function decodeManagedCommandLine(trimmedLine) {
335
349
  return literal[1];
336
350
  return null;
337
351
  }
338
- /**
339
- * Return `contents` with every well-formed managed BEGIN/END block removed
340
- * (inclusive), so what remains is only the bare/root TOML. Used by
341
- * `hasCleanEnforcingHookEntry` to enforce host isolation. If the markers are
342
- * unbalanced (orphan), returns the input unchanged — the caller is only reached
343
- * in the `absent` state, where a well-formed foreign block is the norm.
344
- */
345
- function stripManagedBlockLines(contents) {
346
- const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
347
- const paired = pairBlocks(lines);
348
- if (!("blocks" in paired))
349
- return contents;
350
- const inBlock = new Set();
351
- for (const block of paired.blocks) {
352
- for (let i = block.beginLine; i <= block.endLine; i += 1)
353
- inBlock.add(i);
354
- }
355
- if (inBlock.size === 0)
356
- return contents;
357
- return lines.filter((_, i) => !inBlock.has(i)).join("\n");
358
- }
359
352
  /**
360
353
  * True if a parsed `hooks[]` array contains a clean enforcing entry for
361
354
  * `expectedCommand`: `event="PreToolUse"`, byte-exact `command`, NO `matcher`
@@ -364,10 +357,11 @@ function stripManagedBlockLines(contents) {
364
357
  * kimi-code's strict schema). Each host's hook path is distinct, so the command
365
358
  * uniquely identifies this host's entry.
366
359
  */
367
- function hooksArrayHasCleanEntry(hooks, expectedCommand) {
360
+ function countCleanEntries(hooks, expectedCommand) {
368
361
  if (!Array.isArray(hooks))
369
- return false;
362
+ return 0;
370
363
  const allowedKeys = new Set(["event", "command", "timeout"]);
364
+ let count = 0;
371
365
  for (const entry of hooks) {
372
366
  if (entry === null || typeof entry !== "object")
373
367
  continue;
@@ -380,29 +374,37 @@ function hooksArrayHasCleanEntry(hooks, expectedCommand) {
380
374
  continue;
381
375
  if (!Object.keys(rec).every((k) => allowedKeys.has(k)))
382
376
  continue;
383
- return true;
377
+ count += 1;
384
378
  }
385
- return false;
379
+ return count;
380
+ }
381
+ function hooksArrayHasCleanEntry(hooks, expectedCommand) {
382
+ return countCleanEntries(hooks, expectedCommand) > 0;
386
383
  }
387
384
  /**
388
- * Parser-based fallback used only in the absent state. Strip every managed
389
- * block first so another host's live hook cannot count for this host, then parse
390
- * the remaining bare/root TOML with the real parser rather than the prune line
391
- * scanner. `hooksArrayHasCleanEntry` requires the exact event and command, no
392
- * matcher in any spelling, and no foreign keys. Parse errors fail closed.
393
- * Callers run whole-file schema validation first, including timeout ranges.
385
+ * Parser-based fallback used only in the absent state. Parse the WHOLE file
386
+ * exactly as kimi-code does: deleting managed-block lines first would change
387
+ * which table their keys bind to (a `matcher` written before another host's
388
+ * `[[hooks]]` header belongs to the preceding table, possibly this host's bare
389
+ * one). Host isolation is kept by counting: every managed block whose body
390
+ * carries this exact command may account for one clean entry, so only a clean
391
+ * entry beyond those can be this host's marker-less table. Requires the exact
392
+ * event and command, no matcher in any spelling, and no foreign keys. Parse
393
+ * errors fail closed. Callers run whole-file schema validation first.
394
394
  */
395
395
  export function hasCleanEnforcingHookEntry(contents, expectedCommand) {
396
396
  let parsed;
397
397
  try {
398
- parsed = parseToml(stripManagedBlockLines(contents));
398
+ parsed = parseToml(contents);
399
399
  }
400
400
  catch {
401
401
  return false;
402
402
  }
403
403
  if (parsed === null || typeof parsed !== "object")
404
404
  return false;
405
- return hooksArrayHasCleanEntry(parsed.hooks, expectedCommand);
405
+ const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
406
+ const managedWithCommand = pairBlocks(lines).blocks.filter((block) => block.commandPath === expectedCommand).length;
407
+ return countCleanEntries(parsed.hooks, expectedCommand) > managedWithCommand;
406
408
  }
407
409
  /**
408
410
  * Whole-file parser check for the FOUND (marked-block present) path. The line
@@ -468,7 +470,7 @@ export function evaluateInstalled(contents, expectedCommand, opts) {
468
470
  if (state.kind === "orphan") {
469
471
  return {
470
472
  installed: false,
471
- reason: `${state.detail} marker. Run /kimi:setup --uninstall to clear.`,
473
+ reason: `${state.detail} marker. ${ORPHAN_MARKER_REMEDY}`,
472
474
  state,
473
475
  };
474
476
  }
@@ -516,12 +518,9 @@ export function findBareApprovalHookTables(contents) {
516
518
  const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
517
519
  const tableLines = scanTomlLines(contents).tables;
518
520
  const managed = new Set();
519
- const paired = pairBlocks(lines);
520
- if ("blocks" in paired) {
521
- for (const block of paired.blocks) {
522
- for (let i = block.beginLine; i <= block.endLine; i += 1)
523
- managed.add(i);
524
- }
521
+ for (const block of pairBlocks(lines).blocks) {
522
+ for (let i = block.beginLine; i <= block.endLine; i += 1)
523
+ managed.add(i);
525
524
  }
526
525
  const ranges = [];
527
526
  for (let i = 0; i < lines.length; i += 1) {
@@ -194,23 +194,41 @@ export function maxTestedMinor() {
194
194
  ? entry
195
195
  : max);
196
196
  }
197
+ function compareVersions(a, b) {
198
+ const pa = a.split(".").map((part) => Number.parseInt(part, 10));
199
+ const pb = b.split(".").map((part) => Number.parseInt(part, 10));
200
+ for (let i = 0; i < 3; i += 1) {
201
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
202
+ if (diff !== 0)
203
+ return diff;
204
+ }
205
+ return 0;
206
+ }
197
207
  /**
198
208
  * Format a user-facing warning line for an out-of-range version probe.
199
209
  * Setup itself may complete, but the execution-plan gate will refuse model
200
210
  * spawns until the operator selects a certified binary or updates the plugin.
211
+ * `nativeV2Certified` is the exact native-v2 table; both engines are named so
212
+ * the newest certified version is not misreported as the legacy-v1 bound.
201
213
  */
202
- export function formatVersionOutOfRangeWarning(probe, pluginVersion) {
214
+ export function formatVersionOutOfRangeWarning(probe, pluginVersion, nativeV2Certified = []) {
203
215
  const tested = KIMI_TESTED_MINORS.map((entry) => `${entry.major}.${entry.minor}.x`).join(", ");
204
216
  const max = maxTestedMinor();
205
- const aboveMax = probe.major > max.major || (probe.major === max.major && probe.minor > max.minor);
217
+ const newestNative = [...nativeV2Certified].sort(compareVersions).at(-1);
218
+ const probeRaw = `${probe.major}.${probe.minor}.${probe.patch}`;
219
+ const aboveMax = newestNative !== undefined
220
+ ? compareVersions(probeRaw, newestNative) > 0
221
+ : probe.major > max.major || (probe.major === max.major && probe.minor > max.minor);
222
+ const certified = nativeV2Certified.length > 0
223
+ ? `legacy-v1 ${tested}; native-v2 exact ${nativeV2Certified.join(", ")}`
224
+ : tested;
206
225
  const lines = [
207
- `WARNING: kimi-code version ${probe.version} is outside the range kimi-plugin-cc ${pluginVersion} was tested against (${tested}).`,
226
+ `WARNING: kimi-code version ${probe.version} is outside the versions kimi-plugin-cc ${pluginVersion} certifies (${certified}).`,
208
227
  ];
209
228
  if (aboveMax) {
210
- // Above the legacy-v1 tested upper bound is a release newer than the last
211
- // legacy compatibility audit, commonly from out-of-band auto-upgrade drift.
212
- lines.push(` This is NEWER than the newest version we have tested (${max.major}.${max.minor}.x) and is not yet certified for model execution.`);
229
+ // Commonly out-of-band auto-upgrade drift past the last certification.
230
+ lines.push(` This is NEWER than the newest certified version (${newestNative ?? `${max.major}.${max.minor}.x`}) and is not yet certified for model execution.`);
213
231
  }
214
- lines.push(` Setup can complete, but model-spawning commands will refuse before Kimi starts rather than run an unreviewed engine/version route.`, ` Update kimi-plugin-cc to a release that certifies this minor, or point KIMI_PLUGIN_CC_KIMI_BIN at a certified kimi-code binary and retry setup.`, ` KIMI_PLUGIN_CC_SKIP_VERSION_PROBE is a test/smoke seam, not a production compatibility override.`);
232
+ lines.push(` Setup can complete, but model-spawning commands will refuse before Kimi starts rather than run an unreviewed engine/version route.`, ` Update kimi-plugin-cc to a release that certifies this version, or point KIMI_PLUGIN_CC_KIMI_BIN at a certified kimi-code binary and retry setup.`, ` KIMI_PLUGIN_CC_SKIP_VERSION_PROBE is a test/smoke seam, not a production compatibility override.`);
215
233
  return lines.join("\n");
216
234
  }
@@ -4,4 +4,4 @@
4
4
  // and AGENTS.md on every release. A future improvement would read this from
5
5
  // package.json at build time, but that adds a build-step dependency we
6
6
  // don't want yet.
7
- export const KIMI_PLUGIN_CC_VERSION = "2.5.4";
7
+ export const KIMI_PLUGIN_CC_VERSION = "2.5.5";
@@ -26,9 +26,9 @@ import { readPluginConfig, writePluginConfig } from "../config.js";
26
26
  import { RuntimeError } from "../errors.js";
27
27
  import { normalizeTopLevelInlineHooks, validateKimiHookSetForEnvironment, withKimiConfigLock, } from "../hooks/config-safety.js";
28
28
  import { buildHookShellCommand, hostIdFromHookCommand, resolveHookScriptPath, resolveHostId, resolveNodeBinary, } from "../hooks/install-paths.js";
29
- import { decodeManagedCommandLine, effectiveHost, evaluateInstalled, findUnmanagedApprovalHookBlocks, markerCommentLines, MARKERS, parseManagedBlock, } from "../hooks/managed-block.js";
29
+ import { decodeManagedCommandLine, effectiveHost, evaluateInstalled, findUnmanagedApprovalHookBlocks, markerCommentLines, MARKERS, ORPHAN_MARKER_REMEDY, parseManagedBlock, } from "../hooks/managed-block.js";
30
30
  import { formatVersionOutOfRangeWarning, probeKimiVersion, } from "../kimi-version-probe.js";
31
- import { isNativeV2CertifiedVersion } from "../kimi-engine.js";
31
+ import { isNativeV2CertifiedVersion, NATIVE_V2_CERTIFIED_VERSIONS } from "../kimi-engine.js";
32
32
  import { resolveKimiHome } from "../kimi-home.js";
33
33
  import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
34
34
  import { KIMI_PLUGIN_CC_VERSION } from "../version.js";
@@ -180,7 +180,7 @@ async function runInstall(configPath, hookScriptPath, hostId, reviewGateEnabled,
180
180
  const block = buildManagedBlock(hookScriptPath, context.env, lineEnding, hostId);
181
181
  const next = state.kind === "found"
182
182
  ? spliceBlock(existing, state.beginLine, state.endLine, block, lineEnding)
183
- : appendBlock(existing, block, lineEnding);
183
+ : insertBlockNearManagedBlocks(existing, blocks, block, lineEnding);
184
184
  await assertKimiHookSetValid(next, configPath, "setup.install", context.env);
185
185
  const blockWritten = next !== original;
186
186
  if (blockWritten)
@@ -256,7 +256,10 @@ async function runCheck(configPath, hookScriptPath, hostId, reviewGateEnabled, w
256
256
  nodeExists: (binPath) => existsSync(binPath),
257
257
  });
258
258
  // Non-blocking coexistence + cruft notices.
259
- const { blocks: allBlocks } = parseManagedBlock(existing, hostId);
259
+ const { blocks: allBlocks, foreignOrphans } = parseManagedBlock(existing, hostId);
260
+ for (const orphan of foreignOrphans) {
261
+ warnings.push(`Host "${orphan.hostId}" has an orphaned ${orphan.detail} managed marker at line ${orphan.line + 1}; that host's commands refuse until it is repaired. ${ORPHAN_MARKER_REMEDY}`);
262
+ }
260
263
  const otherHosts = [
261
264
  ...new Set(allBlocks.map((b) => effectiveHost(b, hostId)).filter((h) => h !== hostId)),
262
265
  ];
@@ -303,7 +306,9 @@ async function runCheck(configPath, hookScriptPath, hostId, reviewGateEnabled, w
303
306
  reviewGateEnabled,
304
307
  nextStep: installedCheck.state.kind === "absent"
305
308
  ? "Run /kimi:setup (without --check) to install the managed block."
306
- : "Run /kimi:setup --uninstall, then /kimi:setup to repair.",
309
+ : installedCheck.state.kind === "orphan"
310
+ ? ORPHAN_MARKER_REMEDY
311
+ : "Run /kimi:setup --uninstall, then /kimi:setup to repair.",
307
312
  details: buildDetails({
308
313
  configPath,
309
314
  hookScriptPath,
@@ -535,6 +540,52 @@ function spliceBlock(contents, beginLine, endLine, replacement, lineEnding) {
535
540
  const replacementWithEnding = replacement.endsWith(lineEnding) ? replacement : `${replacement}${lineEnding}`;
536
541
  return `${beforeWithSep}${replacementWithEnding}${after}`;
537
542
  }
543
+ // Keep every host's block in one contiguous run. Magpie's table rewrite drops
544
+ // comments that follow a table it owns (yetone/magpie#5614): a block appended
545
+ // after such a table loses its BEGIN marker and header, leaving an orphan END.
546
+ // Insert after the last managed END only when nothing but blanks/comments
547
+ // precedes the next header AND the real parser proves the result equals the
548
+ // appended form apart from hook order, so no field can rebind and no value can
549
+ // be split; otherwise append.
550
+ function insertBlockNearManagedBlocks(contents, blocks, block, lineEnding) {
551
+ if (blocks.length === 0)
552
+ return appendBlock(contents, block, lineEnding);
553
+ const anchor = Math.max(...blocks.map((b) => b.endLine));
554
+ const lines = splitPreservingEnding(contents);
555
+ for (let i = anchor + 1; i < lines.length; i += 1) {
556
+ const trimmed = lines[i].trim();
557
+ if (trimmed.startsWith("["))
558
+ break;
559
+ if (trimmed.length > 0 && !trimmed.startsWith("#"))
560
+ return appendBlock(contents, block, lineEnding);
561
+ }
562
+ const rawLines = contents.split(/(?<=\n)/);
563
+ const head = rawLines.slice(0, anchor + 1).join("");
564
+ const tail = rawLines.slice(anchor + 1).join("");
565
+ const appended = appendBlock(contents, block, lineEnding);
566
+ if (tail.length === 0)
567
+ return appended;
568
+ const headWithEnding = head.endsWith("\n") ? head : `${head}${lineEnding}`;
569
+ const body = block.endsWith(lineEnding) ? block : `${block}${lineEnding}`;
570
+ const tailSeparator = /^\r?\n/.test(tail) ? "" : lineEnding;
571
+ const inserted = `${headWithEnding}${lineEnding}${body}${tailSeparator}${tail}`;
572
+ return sameTomlIgnoringHookOrder(inserted, appended) ? inserted : appended;
573
+ }
574
+ function sameTomlIgnoringHookOrder(a, b) {
575
+ const canonical = (text) => {
576
+ const parsed = parseToml(text);
577
+ const hooks = Array.isArray(parsed.hooks)
578
+ ? parsed.hooks.map((entry) => JSON.stringify(entry)).sort()
579
+ : parsed.hooks;
580
+ return JSON.stringify({ ...parsed, hooks });
581
+ };
582
+ try {
583
+ return canonical(a) === canonical(b);
584
+ }
585
+ catch {
586
+ return false;
587
+ }
588
+ }
538
589
  function appendBlock(contents, block, lineEnding) {
539
590
  const base = contents.length === 0 || contents.endsWith(lineEnding) ? contents : `${contents}${lineEnding}`;
540
591
  const separator = base.length === 0 ? "" : lineEnding;
@@ -928,7 +979,7 @@ async function collectKimiVersionWarnings(env, warnings) {
928
979
  // v2-certified binary would wrongly warn "not certified, commands will refuse".
929
980
  if (probe.inTestedRange || isNativeV2CertifiedVersion(probe.version))
930
981
  return;
931
- warnings.push(formatVersionOutOfRangeWarning(probe, KIMI_PLUGIN_CC_VERSION));
982
+ warnings.push(formatVersionOutOfRangeWarning(probe, KIMI_PLUGIN_CC_VERSION, NATIVE_V2_CERTIFIED_VERSIONS));
932
983
  }
933
984
  /**
934
985
  * Non-blocking notice for kimi-code's own enabled plugins from
@@ -26,6 +26,12 @@
26
26
  import { parse as parseToml } from "../vendor/smol-toml/parse.js";
27
27
  import { classifyHookCommandDrift, describeHookCommandDrift, hostIdFromHookCommand, isOurApprovalHookCommand, } from "./install-paths.js";
28
28
  const MARKER_TAG = "kimi-plugin-cc-managed";
29
+ /**
30
+ * Plain install removes unpaired marker comments for every host and leaves each
31
+ * host's hook table in place. A scoped uninstall removes only this host's
32
+ * markers, so it would disarm a healthy block and keep another host's orphan.
33
+ */
34
+ export const ORPHAN_MARKER_REMEDY = "Run /kimi:setup (without --uninstall or --check) to repair: install removes orphaned managed marker comments for every host and keeps each host's hook table. A scoped --uninstall does not remove another host's orphan marker. Another tool rewriting ~/.kimi-code/config.toml usually causes this.";
29
35
  /**
30
36
  * Strict BEGIN matcher. Accepts `# === BEGIN kimi-plugin-cc-managed`
31
37
  * with an optional `:<host-id>` suffix, an optional ` (vX.Y.Z)` suffix,
@@ -135,40 +141,41 @@ function findMarkers(lines) {
135
141
  return hits;
136
142
  }
137
143
  /**
138
- * Pair BEGIN/END markers into well-formed blocks. Return the first orphan
139
- * (BEGIN-without-END or END-without-BEGIN); verification fails closed on either.
144
+ * Pair BEGIN/END markers into well-formed blocks. A block is a BEGIN followed
145
+ * immediately (in marker order) by an END with the same host suffix; every
146
+ * other marker is an orphan. Nested or interleaved markers therefore orphan
147
+ * both sides rather than being guessed into a block. Callers decide which
148
+ * orphans fail closed (see `parseManagedBlock`).
140
149
  */
141
150
  function pairBlocks(lines) {
142
151
  const markers = findMarkers(lines);
143
152
  const blocks = [];
144
- let open = null;
145
- for (const marker of markers) {
146
- if (marker.kind === "begin") {
147
- // A second BEGIN before the previous one closed → the previous is orphaned.
148
- if (open !== null)
149
- return { orphan: "BEGIN-without-END" };
150
- open = marker;
153
+ const orphans = [];
154
+ let i = 0;
155
+ while (i < markers.length) {
156
+ const marker = markers[i];
157
+ const next = markers[i + 1];
158
+ if (marker.kind === "begin" && next !== undefined && next.kind === "end" && next.hostId === marker.hostId) {
159
+ const body = validateBlockBody(lines.slice(marker.index + 1, next.index));
160
+ blocks.push({
161
+ hostId: marker.hostId,
162
+ beginLine: marker.index,
163
+ endLine: next.index,
164
+ commandPath: body.commandPath,
165
+ valid: body.valid,
166
+ invalidReason: body.invalidReason,
167
+ });
168
+ i += 2;
151
169
  continue;
152
170
  }
153
- // end
154
- if (open === null)
155
- return { orphan: "END-without-BEGIN" };
156
- if (open.hostId !== marker.hostId)
157
- return { orphan: "BEGIN-without-END" };
158
- const body = validateBlockBody(lines.slice(open.index + 1, marker.index));
159
- blocks.push({
160
- hostId: open.hostId,
161
- beginLine: open.index,
162
- endLine: marker.index,
163
- commandPath: body.commandPath,
164
- valid: body.valid,
165
- invalidReason: body.invalidReason,
171
+ orphans.push({
172
+ line: marker.index,
173
+ detail: marker.kind === "begin" ? "BEGIN-without-END" : "END-without-BEGIN",
174
+ hostId: marker.hostId,
166
175
  });
167
- open = null;
176
+ i += 1;
168
177
  }
169
- if (open !== null)
170
- return { orphan: "BEGIN-without-END" };
171
- return { blocks };
178
+ return { blocks, orphans };
172
179
  }
173
180
  /** Validate the lines BETWEEN a BEGIN/END pair against the safety grammar. */
174
181
  function validateBlockBody(rawBody) {
@@ -274,24 +281,30 @@ function validateBlockBody(rawBody) {
274
281
  export function parseManagedBlock(contents, hostId) {
275
282
  const rawLines = contents.split("\n");
276
283
  const lines = rawLines.map((line) => line.replace(/\r$/, ""));
277
- const paired = pairBlocks(lines);
278
- if ("orphan" in paired) {
279
- return { state: { kind: "orphan", detail: paired.orphan }, blocks: [], lines };
284
+ const { blocks, orphans } = pairBlocks(lines);
285
+ // An orphan suffixed for ANOTHER host cannot be part of this host's block:
286
+ // this host's verdict still rests on its own well-formed pair, the whole-file
287
+ // hook-set validation, and the real-parser cleanliness check. Legacy and own
288
+ // orphans stay fail-closed because they may be this host's damaged block.
289
+ const ownOrphan = orphans.find((o) => o.hostId === null || o.hostId === hostId);
290
+ const foreignOrphans = orphans.filter((o) => o.hostId !== null && o.hostId !== hostId);
291
+ if (ownOrphan !== undefined) {
292
+ return { state: { kind: "orphan", detail: ownOrphan.detail }, blocks: [], lines, foreignOrphans };
280
293
  }
281
- const blocks = paired.blocks;
282
294
  // A block belongs to this host when its marker suffix matches or, for a
283
295
  // legacy un-suffixed block, its command derives to this host. An unattributable
284
296
  // legacy command is claimable by the next installer; a command attributed to
285
297
  // another host is ignored so its block remains untouched.
286
298
  const relevant = blocks.filter((b) => effectiveHost(b, hostId) === hostId);
287
299
  if (relevant.length === 0) {
288
- return { state: { kind: "absent" }, blocks, lines };
300
+ return { state: { kind: "absent" }, blocks, lines, foreignOrphans };
289
301
  }
290
302
  if (relevant.length > 1) {
291
303
  return {
292
304
  state: { kind: "duplicate", beginLines: relevant.map((b) => b.beginLine) },
293
305
  blocks,
294
306
  lines,
307
+ foreignOrphans,
295
308
  };
296
309
  }
297
310
  const block = relevant[0];
@@ -307,6 +320,7 @@ export function parseManagedBlock(contents, hostId) {
307
320
  },
308
321
  blocks,
309
322
  lines,
323
+ foreignOrphans,
310
324
  };
311
325
  }
312
326
  /**
@@ -335,27 +349,6 @@ export function decodeManagedCommandLine(trimmedLine) {
335
349
  return literal[1];
336
350
  return null;
337
351
  }
338
- /**
339
- * Return `contents` with every well-formed managed BEGIN/END block removed
340
- * (inclusive), so what remains is only the bare/root TOML. Used by
341
- * `hasCleanEnforcingHookEntry` to enforce host isolation. If the markers are
342
- * unbalanced (orphan), returns the input unchanged — the caller is only reached
343
- * in the `absent` state, where a well-formed foreign block is the norm.
344
- */
345
- function stripManagedBlockLines(contents) {
346
- const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
347
- const paired = pairBlocks(lines);
348
- if (!("blocks" in paired))
349
- return contents;
350
- const inBlock = new Set();
351
- for (const block of paired.blocks) {
352
- for (let i = block.beginLine; i <= block.endLine; i += 1)
353
- inBlock.add(i);
354
- }
355
- if (inBlock.size === 0)
356
- return contents;
357
- return lines.filter((_, i) => !inBlock.has(i)).join("\n");
358
- }
359
352
  /**
360
353
  * True if a parsed `hooks[]` array contains a clean enforcing entry for
361
354
  * `expectedCommand`: `event="PreToolUse"`, byte-exact `command`, NO `matcher`
@@ -364,10 +357,11 @@ function stripManagedBlockLines(contents) {
364
357
  * kimi-code's strict schema). Each host's hook path is distinct, so the command
365
358
  * uniquely identifies this host's entry.
366
359
  */
367
- function hooksArrayHasCleanEntry(hooks, expectedCommand) {
360
+ function countCleanEntries(hooks, expectedCommand) {
368
361
  if (!Array.isArray(hooks))
369
- return false;
362
+ return 0;
370
363
  const allowedKeys = new Set(["event", "command", "timeout"]);
364
+ let count = 0;
371
365
  for (const entry of hooks) {
372
366
  if (entry === null || typeof entry !== "object")
373
367
  continue;
@@ -380,29 +374,37 @@ function hooksArrayHasCleanEntry(hooks, expectedCommand) {
380
374
  continue;
381
375
  if (!Object.keys(rec).every((k) => allowedKeys.has(k)))
382
376
  continue;
383
- return true;
377
+ count += 1;
384
378
  }
385
- return false;
379
+ return count;
380
+ }
381
+ function hooksArrayHasCleanEntry(hooks, expectedCommand) {
382
+ return countCleanEntries(hooks, expectedCommand) > 0;
386
383
  }
387
384
  /**
388
- * Parser-based fallback used only in the absent state. Strip every managed
389
- * block first so another host's live hook cannot count for this host, then parse
390
- * the remaining bare/root TOML with the real parser rather than the prune line
391
- * scanner. `hooksArrayHasCleanEntry` requires the exact event and command, no
392
- * matcher in any spelling, and no foreign keys. Parse errors fail closed.
393
- * Callers run whole-file schema validation first, including timeout ranges.
385
+ * Parser-based fallback used only in the absent state. Parse the WHOLE file
386
+ * exactly as kimi-code does: deleting managed-block lines first would change
387
+ * which table their keys bind to (a `matcher` written before another host's
388
+ * `[[hooks]]` header belongs to the preceding table, possibly this host's bare
389
+ * one). Host isolation is kept by counting: every managed block whose body
390
+ * carries this exact command may account for one clean entry, so only a clean
391
+ * entry beyond those can be this host's marker-less table. Requires the exact
392
+ * event and command, no matcher in any spelling, and no foreign keys. Parse
393
+ * errors fail closed. Callers run whole-file schema validation first.
394
394
  */
395
395
  export function hasCleanEnforcingHookEntry(contents, expectedCommand) {
396
396
  let parsed;
397
397
  try {
398
- parsed = parseToml(stripManagedBlockLines(contents));
398
+ parsed = parseToml(contents);
399
399
  }
400
400
  catch {
401
401
  return false;
402
402
  }
403
403
  if (parsed === null || typeof parsed !== "object")
404
404
  return false;
405
- return hooksArrayHasCleanEntry(parsed.hooks, expectedCommand);
405
+ const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
406
+ const managedWithCommand = pairBlocks(lines).blocks.filter((block) => block.commandPath === expectedCommand).length;
407
+ return countCleanEntries(parsed.hooks, expectedCommand) > managedWithCommand;
406
408
  }
407
409
  /**
408
410
  * Whole-file parser check for the FOUND (marked-block present) path. The line
@@ -468,7 +470,7 @@ export function evaluateInstalled(contents, expectedCommand, opts) {
468
470
  if (state.kind === "orphan") {
469
471
  return {
470
472
  installed: false,
471
- reason: `${state.detail} marker. Run /kimi:setup --uninstall to clear.`,
473
+ reason: `${state.detail} marker. ${ORPHAN_MARKER_REMEDY}`,
472
474
  state,
473
475
  };
474
476
  }
@@ -516,12 +518,9 @@ export function findBareApprovalHookTables(contents) {
516
518
  const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
517
519
  const tableLines = scanTomlLines(contents).tables;
518
520
  const managed = new Set();
519
- const paired = pairBlocks(lines);
520
- if ("blocks" in paired) {
521
- for (const block of paired.blocks) {
522
- for (let i = block.beginLine; i <= block.endLine; i += 1)
523
- managed.add(i);
524
- }
521
+ for (const block of pairBlocks(lines).blocks) {
522
+ for (let i = block.beginLine; i <= block.endLine; i += 1)
523
+ managed.add(i);
525
524
  }
526
525
  const ranges = [];
527
526
  for (let i = 0; i < lines.length; i += 1) {