kimi-plugin-cc 2.5.1 → 2.5.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.
@@ -9,7 +9,8 @@ import { SETUP_SUPPORTED_FLAGS } from "../parsing.js";
9
9
  // absolute Node path avoids PATH dependence under sanitized launches. Setup
10
10
  // probes the hook directly with resolveNodeBinary and through `/bin/sh -c`
11
11
  // using the exact installed command bytes. It also warns about broad permission
12
- // denies and refuses duplicate, orphaned, or malformed managed blocks.
12
+ // denies, repairs orphan marker comments, and refuses remaining
13
+ // duplicate, orphaned, or malformed managed blocks.
13
14
  //
14
15
  // Subcommands: install (default), --check, --uninstall [--all], and
15
16
  // --enable-review-gate/--disable-review-gate.
@@ -25,12 +26,13 @@ import { readPluginConfig, writePluginConfig } from "../config.js";
25
26
  import { RuntimeError } from "../errors.js";
26
27
  import { normalizeTopLevelInlineHooks, validateKimiHookSetForEnvironment, withKimiConfigLock, } from "../hooks/config-safety.js";
27
28
  import { buildHookShellCommand, hostIdFromHookCommand, resolveHookScriptPath, resolveHostId, resolveNodeBinary, } from "../hooks/install-paths.js";
28
- import { decodeManagedCommandLine, effectiveHost, evaluateInstalled, findUnmanagedApprovalHookBlocks, MARKERS, parseManagedBlock, } from "../hooks/managed-block.js";
29
+ import { decodeManagedCommandLine, effectiveHost, evaluateInstalled, findUnmanagedApprovalHookBlocks, markerCommentLines, MARKERS, parseManagedBlock, } from "../hooks/managed-block.js";
29
30
  import { formatVersionOutOfRangeWarning, probeKimiVersion, } from "../kimi-version-probe.js";
30
31
  import { isNativeV2CertifiedVersion } from "../kimi-engine.js";
31
32
  import { resolveKimiHome } from "../kimi-home.js";
32
33
  import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
33
34
  import { KIMI_PLUGIN_CC_VERSION } from "../version.js";
35
+ import { parse as parseToml } from "../vendor/smol-toml/parse.js";
34
36
  const BEGIN_MARKER_PREFIX = "# === BEGIN kimi-plugin-cc-managed";
35
37
  const DEFAULT_HOOK_TIMEOUT_S = 15;
36
38
  // Probe timing. The direct script probe is local and fast; allow 5s for
@@ -119,7 +121,17 @@ async function runInstall(configPath, hookScriptPath, hostId, reviewGateEnabled,
119
121
  const mutation = await withKimiConfigLock(configPath, async () => {
120
122
  const original = await readConfigSafe(configPath);
121
123
  const lineEnding = detectLineEnding(original);
122
- const inlineHooks = normalizeTopLevelInlineHooks(original, lineEnding);
124
+ try {
125
+ parseToml(original);
126
+ }
127
+ catch {
128
+ await assertKimiHookSetValid(original, configPath, "setup.install", context.env);
129
+ }
130
+ const repaired = removeOrphanMarkers(original);
131
+ if (repaired.count > 0) {
132
+ warnings.push(`Removed ${repaired.count} orphaned managed marker comment(s); surrounding config was preserved.`);
133
+ }
134
+ const inlineHooks = normalizeTopLevelInlineHooks(repaired.contents, lineEnding);
123
135
  if (inlineHooks.found) {
124
136
  // Validate before normalizing so an invalid inline entry is reported
125
137
  // against the user's original bytes and the config remains untouched.
@@ -133,7 +145,7 @@ async function runInstall(configPath, hookScriptPath, hostId, reviewGateEnabled,
133
145
  // splice line numbers. Another host's marker-less table may be its live hook
134
146
  // after a config rewrite strips comments, so it must remain untouched.
135
147
  const expectedCommand = buildHookShellCommand(hookScriptPath, context.env);
136
- const { pruned, count: prunedCount, commands: prunedCommands } = pruneOrphanApprovalHooks(inlineHooks.contents, lineEnding, hostId, expectedCommand);
148
+ const { pruned, count: prunedCount, commands: prunedCommands } = pruneOrphanApprovalHooks(inlineHooks.contents, hostId, expectedCommand);
137
149
  const existing = pruned;
138
150
  if (prunedCount > 0) {
139
151
  const readorned = prunedCommands.includes(expectedCommand);
@@ -145,7 +157,7 @@ async function runInstall(configPath, hookScriptPath, hostId, reviewGateEnabled,
145
157
  if (state.kind === "orphan") {
146
158
  throw new RuntimeError("SETUP_ORPHAN_MARKERS", [
147
159
  `kimi-code config at ${configPath} contains an orphaned ${state.detail} marker.`,
148
- "Run `/kimi:setup --uninstall` to clean up, then re-run `/kimi:setup`.",
160
+ "Inspect the managed marker comments and re-run setup.",
149
161
  ].join(" "), "setup.install", { details: { configPath, orphan: state.detail } });
150
162
  }
151
163
  if (state.kind === "duplicate") {
@@ -393,12 +405,11 @@ async function runUninstallLocked(configPath, hookScriptPath, hostId, removeAllH
393
405
  // other hosts' blocks and live marker-less tables remain intact. `--all` is
394
406
  // the explicit every-host sweep for all of this plugin's hook tables.
395
407
  const { stripped: strippedMarkers, removedBlocks, orphansLeft } = stripManagedBlocks(existing, hostId, removeAllHosts);
396
- const lineEnding = detectLineEnding(existing);
397
408
  // Exact current command bytes also identify this host's table when a host-id
398
409
  // override disagrees with path derivation or a dev path is outside the strict
399
410
  // ownership pattern.
400
411
  const expectedCommand = buildHookShellCommand(hookScriptPath, context.env);
401
- const { pruned: stripped, count: prunedCount } = pruneOrphanApprovalHooks(strippedMarkers, lineEnding, removeAllHosts ? undefined : hostId, expectedCommand);
412
+ const { pruned: stripped, count: prunedCount } = pruneOrphanApprovalHooks(strippedMarkers, removeAllHosts ? undefined : hostId, expectedCommand);
402
413
  const changed = stripped !== existing;
403
414
  if (changed) {
404
415
  await writeConfigAtomic(configPath, stripped);
@@ -517,10 +528,10 @@ function detectLineEnding(contents) {
517
528
  return contents.includes("\r\n") ? "\r\n" : "\n";
518
529
  }
519
530
  function spliceBlock(contents, beginLine, endLine, replacement, lineEnding) {
520
- const lines = splitPreservingEnding(contents);
521
- const before = lines.slice(0, beginLine).join(lineEnding);
522
- const after = lines.slice(endLine + 1).join(lineEnding);
523
- const beforeWithSep = before.endsWith(lineEnding) || before.length === 0 ? before : `${before}${lineEnding}`;
531
+ const lines = contents.split(/(?<=\n)/);
532
+ const before = lines.slice(0, beginLine).join("");
533
+ const after = lines.slice(endLine + 1).join("");
534
+ const beforeWithSep = before.endsWith("\n") || before.length === 0 ? before : `${before}${lineEnding}`;
524
535
  const replacementWithEnding = replacement.endsWith(lineEnding) ? replacement : `${replacement}${lineEnding}`;
525
536
  return `${beforeWithSep}${replacementWithEnding}${after}`;
526
537
  }
@@ -536,14 +547,54 @@ function splitPreservingEnding(contents) {
536
547
  // pass the desired line ending).
537
548
  return contents.split("\n").map((line) => line.replace(/\r$/, ""));
538
549
  }
550
+ // A partial upstream hooks-array rewrite can leave one marker comment behind.
551
+ // Remove only unpaired marker comments, never the intervening content. The
552
+ // normal full-file validation still gates writes.
553
+ function removeOrphanMarkers(contents) {
554
+ const lines = splitPreservingEnding(contents);
555
+ const orphans = [];
556
+ const comments = markerCommentLines(contents);
557
+ let open = null;
558
+ for (let i = 0; i < lines.length; i += 1) {
559
+ if (!comments.has(i))
560
+ continue;
561
+ const line = lines[i].trim();
562
+ const begin = MARKERS.BEGIN_LINE_RE.exec(line);
563
+ if (begin !== null) {
564
+ if (open !== null)
565
+ orphans.push(open);
566
+ open = { index: i, host: begin[1]?.toLowerCase() ?? null };
567
+ }
568
+ else {
569
+ const end = MARKERS.END_LINE_RE.exec(line);
570
+ if (end === null)
571
+ continue;
572
+ const endMarker = { index: i, host: end[1]?.toLowerCase() ?? null };
573
+ if (open === null)
574
+ orphans.push(endMarker);
575
+ else {
576
+ if (open.host !== endMarker.host)
577
+ orphans.push(open, endMarker);
578
+ open = null;
579
+ }
580
+ }
581
+ }
582
+ if (open !== null)
583
+ orphans.push(open);
584
+ const remove = new Set(orphans.map((marker) => marker.index));
585
+ return remove.size === 0
586
+ ? { contents, count: 0 }
587
+ : { contents: contents.split(/(?<=\n)/).filter((_, index) => !remove.has(index)).join(""), count: remove.size };
588
+ }
539
589
  /**
540
590
  * Remove managed blocks owned by `currentHost`, or every host with `removeAll`.
541
591
  * Ownership comes from the marker suffix or a legacy command's derived host.
542
592
  * Orphan markers are removed individually so surrounding user content survives.
543
593
  */
544
594
  function stripManagedBlocks(contents, currentHost, removeAll) {
545
- const lineEnding = detectLineEnding(contents);
546
595
  const lines = splitPreservingEnding(contents);
596
+ const rawLines = contents.split(/(?<=\n)/);
597
+ const markerLines = markerCommentLines(contents);
547
598
  const result = [];
548
599
  const orphansLeft = [];
549
600
  let removedBlocks = 0;
@@ -567,7 +618,7 @@ function stripManagedBlocks(contents, currentHost, removeAll) {
567
618
  while (i < lines.length) {
568
619
  const line = lines[i];
569
620
  const trimmed = line.trim();
570
- const begin = MARKERS.BEGIN_LINE_RE.exec(trimmed);
621
+ const begin = markerLines.has(i) ? MARKERS.BEGIN_LINE_RE.exec(trimmed) : null;
571
622
  if (begin !== null) {
572
623
  const beginHost = begin[1]?.toLowerCase() ?? null;
573
624
  // Look ahead for the matching END, capturing the block's command en route.
@@ -575,12 +626,14 @@ function stripManagedBlocks(contents, currentHost, removeAll) {
575
626
  let blockCommand = null;
576
627
  for (let j = i + 1; j < lines.length; j += 1) {
577
628
  const nextTrimmed = lines[j].trim();
578
- if (MARKERS.BEGIN_LINE_RE.test(nextTrimmed)) {
629
+ if (markerLines.has(j) && MARKERS.BEGIN_LINE_RE.test(nextTrimmed)) {
579
630
  // Two BEGINs in a row — the outer one is an orphan.
580
631
  break;
581
632
  }
582
- if (MARKERS.END_LINE_RE.test(nextTrimmed)) {
583
- endIdx = j;
633
+ const end = markerLines.has(j) ? MARKERS.END_LINE_RE.exec(nextTrimmed) : null;
634
+ if (end !== null) {
635
+ if ((end[1]?.toLowerCase() ?? null) === beginHost)
636
+ endIdx = j;
584
637
  break;
585
638
  }
586
639
  const decoded = decodeManagedCommandLine(nextTrimmed);
@@ -594,7 +647,7 @@ function stripManagedBlocks(contents, currentHost, removeAll) {
594
647
  i += 1;
595
648
  continue;
596
649
  }
597
- result.push(line);
650
+ result.push(rawLines[i]);
598
651
  i += 1;
599
652
  continue;
600
653
  }
@@ -606,11 +659,11 @@ function stripManagedBlocks(contents, currentHost, removeAll) {
606
659
  }
607
660
  // Another host's block — keep it verbatim.
608
661
  for (let k = i; k <= endIdx; k += 1)
609
- result.push(lines[k]);
662
+ result.push(rawLines[k]);
610
663
  i = endIdx + 1;
611
664
  continue;
612
665
  }
613
- const end = MARKERS.END_LINE_RE.exec(trimmed);
666
+ const end = markerLines.has(i) ? MARKERS.END_LINE_RE.exec(trimmed) : null;
614
667
  if (end !== null) {
615
668
  const endHost = end[1]?.toLowerCase() ?? null;
616
669
  // Orphan END with no preceding BEGIN: drop only this line (if ours).
@@ -619,27 +672,14 @@ function stripManagedBlocks(contents, currentHost, removeAll) {
619
672
  i += 1;
620
673
  continue;
621
674
  }
622
- result.push(line);
675
+ result.push(rawLines[i]);
623
676
  i += 1;
624
677
  continue;
625
678
  }
626
- result.push(line);
679
+ result.push(rawLines[i]);
627
680
  i += 1;
628
681
  }
629
- const collapsed = [];
630
- let blankRun = 0;
631
- for (const line of result) {
632
- if (line.length === 0) {
633
- blankRun += 1;
634
- if (blankRun <= 2)
635
- collapsed.push(line);
636
- }
637
- else {
638
- blankRun = 0;
639
- collapsed.push(line);
640
- }
641
- }
642
- return { stripped: collapsed.join(lineEnding), removedBlocks, orphansLeft };
682
+ return { stripped: result.join(""), removedBlocks, orphansLeft };
643
683
  }
644
684
  /**
645
685
  * Remove orphaned, marker-less `[[hooks]]` tables that are unambiguously this
@@ -654,7 +694,7 @@ function stripManagedBlocks(contents, currentHost, removeAll) {
654
694
  * host — REQUIRED for scoped install/uninstall so one host never disarms the
655
695
  * other's live (marker-stripped) hook. Omit only for `--uninstall --all`.
656
696
  */
657
- function pruneOrphanApprovalHooks(contents, lineEnding, ownedBy, alsoMatchCommand) {
697
+ function pruneOrphanApprovalHooks(contents, ownedBy, alsoMatchCommand) {
658
698
  const ranges = findUnmanagedApprovalHookBlocks(contents, ownedBy, alsoMatchCommand);
659
699
  if (ranges.length === 0)
660
700
  return { pruned: contents, count: 0, commands: [] };
@@ -663,23 +703,10 @@ function pruneOrphanApprovalHooks(contents, lineEnding, ownedBy, alsoMatchComman
663
703
  for (let i = start; i < end; i += 1)
664
704
  remove.add(i);
665
705
  }
666
- const lines = splitPreservingEnding(contents);
706
+ const lines = contents.split(/(?<=\n)/);
667
707
  const kept = lines.filter((_, idx) => !remove.has(idx));
668
- const collapsed = [];
669
- let blankRun = 0;
670
- for (const line of kept) {
671
- if (line.length === 0) {
672
- blankRun += 1;
673
- if (blankRun <= 2)
674
- collapsed.push(line);
675
- }
676
- else {
677
- blankRun = 0;
678
- collapsed.push(line);
679
- }
680
- }
681
708
  return {
682
- pruned: collapsed.join(lineEnding),
709
+ pruned: kept.join(""),
683
710
  count: ranges.length,
684
711
  commands: ranges.map((range) => range.command),
685
712
  };
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { brandString } from "@deepseek-ai/dsh-brand";
3
3
  import { assertUsableCwd, settleRunResult, subprocessRunHandle, } from "@deepseek-ai/dsh-subagent";
4
+ import { preferStableNodePath } from "../hooks/install-paths.js";
4
5
  export const DSH_COMPANION_OPERATIONS = [
5
6
  "ask",
6
7
  "review",
@@ -20,7 +21,7 @@ export function companionTextTask(prompt) {
20
21
  }
21
22
  return text;
22
23
  }
23
- export function companionArgv(operation, prompt, companionPath, nodeBin = process.execPath, options = {}) {
24
+ export function companionArgv(operation, prompt, companionPath, nodeBin = preferStableNodePath(process.execPath), options = {}) {
24
25
  const command = operation === "ask" || operation === "review"
25
26
  ? [operation]
26
27
  : operation === "swarm-write"
@@ -41,7 +42,7 @@ export function companionArgv(operation, prompt, companionPath, nodeBin = proces
41
42
  prompt,
42
43
  ];
43
44
  }
44
- export function companionEnvironment(dataRoot, cwd, env, nodeBin = process.execPath) {
45
+ export function companionEnvironment(dataRoot, cwd, env, nodeBin = preferStableNodePath(process.execPath)) {
45
46
  return {
46
47
  ...env,
47
48
  KIMI_PLUGIN_CC_DATA: dataRoot,
@@ -79,8 +80,9 @@ export async function startCompanionRun(request, operation, spec) {
79
80
  request.signal.addEventListener("abort", onAbort, { once: true });
80
81
  let child;
81
82
  try {
83
+ const nodeBin = preferStableNodePath(process.execPath);
82
84
  child = spec.spawn({
83
- argv: companionArgv(operation, prompt, spec.companionPath, process.execPath, spec),
85
+ argv: companionArgv(operation, prompt, spec.companionPath, nodeBin, spec),
84
86
  cwd,
85
87
  stdio: {
86
88
  stdin: "ignore",
@@ -89,7 +91,7 @@ export async function startCompanionRun(request, operation, spec) {
89
91
  },
90
92
  graceMs: spec.disposeGraceMs,
91
93
  signal: termination.signal,
92
- env: companionEnvironment(spec.dataRoot, cwd, spec.env),
94
+ env: companionEnvironment(spec.dataRoot, cwd, spec.env, nodeBin),
93
95
  });
94
96
  }
95
97
  catch (error) {
@@ -141,8 +143,9 @@ export async function startCompanionRun(request, operation, spec) {
141
143
  });
142
144
  }
143
145
  export async function runCompanionCommand(argv, cwd, spec, signal) {
146
+ const nodeBin = preferStableNodePath(process.execPath);
144
147
  const child = spec.spawn({
145
- argv: [process.execPath, spec.companionPath, ...argv],
148
+ argv: [nodeBin, spec.companionPath, ...argv],
146
149
  cwd,
147
150
  stdio: {
148
151
  stdin: "ignore",
@@ -151,7 +154,7 @@ export async function runCompanionCommand(argv, cwd, spec, signal) {
151
154
  },
152
155
  graceMs: spec.disposeGraceMs,
153
156
  signal,
154
- env: companionEnvironment(spec.dataRoot, cwd, spec.env),
157
+ env: companionEnvironment(spec.dataRoot, cwd, spec.env, nodeBin),
155
158
  });
156
159
  try {
157
160
  const outcome = await child.done;
@@ -8,6 +8,12 @@ import { DSH_COMPANION_OPERATIONS, runCompanionCommand, startCompanionRun, } fro
8
8
  import { parseDurationMs } from "../parsing.js";
9
9
  export const name = "kimi-plugin-cc";
10
10
  export const inject = ["subagents", "subprocess"];
11
+ export const DSH_TOOL_GUIDANCE = [
12
+ "Kimi delegation tools have different write boundaries:",
13
+ "kimi_ask, kimi_review, and kimi_challenge are read-only; kimi_swarm is a read-only parallel review.",
14
+ "kimi_rescue and kimi_pursue can edit the selected workspace; kimi_pursue continues toward a bounded goal.",
15
+ "kimi_swarm_write edits a temporary worktree and returns a patch without applying it.",
16
+ ].join(" ");
11
17
  const DEFAULT_DISPOSE_GRACE_MS = 6_000;
12
18
  const DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
13
19
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
@@ -50,8 +56,8 @@ function resolveConfig(config) {
50
56
  if (!path.isAbsolute(resolved.dataRoot)) {
51
57
  throw new Error("kimi-plugin-cc: dataRoot must be absolute");
52
58
  }
53
- if (!Number.isFinite(resolved.disposeGraceMs) || resolved.disposeGraceMs <= 0 || resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
54
- throw new Error(`kimi-plugin-cc: disposeGraceMs must be positive, finite, and no greater than ${MAX_TIMER_DELAY_MS}`);
59
+ if (!Number.isFinite(resolved.disposeGraceMs) || resolved.disposeGraceMs < DEFAULT_DISPOSE_GRACE_MS || resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
60
+ throw new Error(`kimi-plugin-cc: disposeGraceMs must be at least ${DEFAULT_DISPOSE_GRACE_MS} and no greater than ${MAX_TIMER_DELAY_MS}`);
55
61
  }
56
62
  if (!Number.isSafeInteger(resolved.maxOutputBytes) || resolved.maxOutputBytes <= 0) {
57
63
  throw new Error("kimi-plugin-cc: maxOutputBytes must be a positive safe integer");
@@ -105,6 +111,13 @@ export function apply(ctx, config) {
105
111
  for (const operation of DSH_COMPANION_OPERATIONS) {
106
112
  ctx.subagents.registerProvider(new KimiCompanionProvider(`kimi-${operation}`, operation, spec));
107
113
  }
114
+ ctx.inject(["systemPrompt"], (promptCtx) => {
115
+ promptCtx.systemPrompt.section({
116
+ name: "kimi-plugin-cc:tool-boundaries",
117
+ order: promptCtx.systemPrompt.getSectionOrder("TOOL_SUBAGENT"),
118
+ text: () => DSH_TOOL_GUIDANCE,
119
+ });
120
+ });
108
121
  ctx.inject(["commands"], (commandCtx) => {
109
122
  const execute = async (invocation, argv) => {
110
123
  const directories = commandCtx.get("workingDirectory");
@@ -45,10 +45,80 @@ const MATCHER_LINE_RE = /^matcher\s*=/;
45
45
  /** `timeout = <int>` — the only other key our managed [[hooks]] table emits. */
46
46
  const TIMEOUT_LINE_RE = /^timeout\s*=\s*\d+\s*$/;
47
47
  const HOOKS_TABLE_RE = /^\[\[hooks\]\]\s*$/;
48
+ // Recognize comment lines lexically, not text inside TOML string values.
49
+ // Whole-document parsing remains the authority for TOML validity.
50
+ function scanTomlLines(contents) {
51
+ const markers = new Set();
52
+ const tables = new Set();
53
+ let state = "plain";
54
+ let line = 0;
55
+ let blankPrefix = true;
56
+ for (let i = 0; i < contents.length; i += 1) {
57
+ const c = contents[i];
58
+ if (c === "\n") {
59
+ line += 1;
60
+ blankPrefix = true;
61
+ continue;
62
+ }
63
+ const onlyWhitespaceBefore = blankPrefix;
64
+ if (c !== " " && c !== "\t" && c !== "\r")
65
+ blankPrefix = false;
66
+ if (state === "plain") {
67
+ if (c === "[" && onlyWhitespaceBefore)
68
+ tables.add(line);
69
+ if (c === "#") {
70
+ if (onlyWhitespaceBefore) {
71
+ const end = contents.indexOf("\n", i);
72
+ const comment = contents.slice(i, end < 0 ? contents.length : end).trim();
73
+ if (BEGIN_LINE_RE.test(comment) || END_LINE_RE.test(comment))
74
+ markers.add(line);
75
+ }
76
+ const end = contents.indexOf("\n", i);
77
+ if (end < 0)
78
+ break;
79
+ i = end - 1;
80
+ }
81
+ else if (c === '"' || c === "'") {
82
+ const triple = contents.slice(i, i + 3) === c.repeat(3);
83
+ state = c === '"' ? (triple ? "multibasic" : "basic") : (triple ? "multiliteral" : "literal");
84
+ if (triple)
85
+ i += 2;
86
+ }
87
+ }
88
+ else {
89
+ const basic = state === "basic" || state === "multibasic";
90
+ const quote = basic ? '"' : "'";
91
+ if (basic && c === "\\") {
92
+ if (contents[i + 1] === "\n") {
93
+ line += 1;
94
+ blankPrefix = true;
95
+ }
96
+ i += 1;
97
+ continue;
98
+ }
99
+ if (c !== quote)
100
+ continue;
101
+ if (state === "basic" || state === "literal")
102
+ state = "plain";
103
+ else if (contents.slice(i, i + 3) === quote.repeat(3)) {
104
+ while (contents[i + 1] === quote)
105
+ i += 1;
106
+ state = "plain";
107
+ }
108
+ }
109
+ }
110
+ return { markers, tables };
111
+ }
112
+ export function markerCommentLines(contents) {
113
+ return scanTomlLines(contents).markers;
114
+ }
48
115
  /** Locate every BEGIN/END marker line, in order, with its parsed host id. */
49
116
  function findMarkers(lines) {
50
117
  const hits = [];
118
+ const comments = markerCommentLines(lines.join("\n"));
51
119
  for (let i = 0; i < lines.length; i += 1) {
120
+ if (!comments.has(i))
121
+ continue;
52
122
  const trimmed = lines[i].trim();
53
123
  const begin = BEGIN_LINE_RE.exec(trimmed);
54
124
  if (begin !== null) {
@@ -66,7 +136,7 @@ function findMarkers(lines) {
66
136
  }
67
137
  /**
68
138
  * Pair BEGIN/END markers into well-formed blocks. Return the first orphan
69
- * (BEGIN-without-END or END-without-BEGIN); either is a hard error for install.
139
+ * (BEGIN-without-END or END-without-BEGIN); verification fails closed on either.
70
140
  */
71
141
  function pairBlocks(lines) {
72
142
  const markers = findMarkers(lines);
@@ -83,6 +153,8 @@ function pairBlocks(lines) {
83
153
  // end
84
154
  if (open === null)
85
155
  return { orphan: "END-without-BEGIN" };
156
+ if (open.hostId !== marker.hostId)
157
+ return { orphan: "BEGIN-without-END" };
86
158
  const body = validateBlockBody(lines.slice(open.index + 1, marker.index));
87
159
  blocks.push({
88
160
  hostId: open.hostId,
@@ -436,13 +508,13 @@ export function evaluateInstalled(contents, expectedCommand, opts) {
436
508
  * The deletion range `[start, end)` is conservative: only the contiguous
437
509
  * non-blank body after `[[hooks]]`, stopping at a blank line, table header, or
438
510
  * managed marker so adjacent user content is preserved. A separate full-span
439
- * scan rejects matcher lines recognized by MATCHER_LINE_RE to avoid half-cutting
440
- * a table. That scan is deliberately heuristic and does not claim to recognize
441
- * every quoted or dotted TOML matcher spelling. `command` is decoded for the
442
- * caller's ownership checks.
511
+ * scan refuses any further TOML field before the next table, including quoted
512
+ * and dotted keys, so pruning cannot attach those fields to another table.
513
+ * `command` is decoded for the caller's ownership checks.
443
514
  */
444
515
  export function findBareApprovalHookTables(contents) {
445
516
  const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
517
+ const tableLines = scanTomlLines(contents).tables;
446
518
  const managed = new Set();
447
519
  const paired = pairBlocks(lines);
448
520
  if ("blocks" in paired) {
@@ -455,7 +527,7 @@ export function findBareApprovalHookTables(contents) {
455
527
  for (let i = 0; i < lines.length; i += 1) {
456
528
  if (managed.has(i))
457
529
  continue;
458
- if (!HOOKS_TABLE_RE.test(lines[i].trim()))
530
+ if (!tableLines.has(i) || !HOOKS_TABLE_RE.test(lines[i].trim()))
459
531
  continue;
460
532
  // Collect the contiguous table body.
461
533
  let j = i + 1;
@@ -500,22 +572,16 @@ export function findBareApprovalHookTables(contents) {
500
572
  }
501
573
  j += 1;
502
574
  }
503
- // A blank line does NOT end a TOML table; membership continues to the next
504
- // header or EOF. The deletion range nevertheless stops at the blank to
505
- // preserve adjacent user content. Scan the remaining span for matcher lines
506
- // this scanner recognizes and reject the table rather than half-cutting it:
507
- // matcher is schema-valid but can disable the hook at runtime. Foreign keys
508
- // and other TOML spellings are handled by whole-file schema validation and
509
- // the parser-based installed checks, not by this prune heuristic.
510
- let matcherBeyondBody = false;
575
+ // Blank lines and marker comments do not end a TOML table. Never leave any
576
+ // field behind to be rebound to a preceding table after pruning, regardless
577
+ // of the field's spelling or whether it is valid for a hook.
578
+ let fieldBeyondBody = false;
511
579
  for (let k = j; k < lines.length; k += 1) {
512
580
  const t = lines[k].trim();
513
- // The current table's TOML span ends at the next table header or a
514
- // managed marker; a matcher past that point belongs to a different table.
515
- if (t.startsWith("[") || BEGIN_LINE_RE.test(t) || END_LINE_RE.test(t))
581
+ if (tableLines.has(k))
516
582
  break;
517
- if (MATCHER_LINE_RE.test(t)) {
518
- matcherBeyondBody = true;
583
+ if (t.length > 0 && !t.startsWith("#")) {
584
+ fieldBeyondBody = true;
519
585
  break;
520
586
  }
521
587
  }
@@ -527,7 +593,7 @@ export function findBareApprovalHookTables(contents) {
527
593
  commandDecoded.length > 0 &&
528
594
  hasPreToolUseEvent &&
529
595
  !hasMatcher &&
530
- !matcherBeyondBody) {
596
+ !fieldBeyondBody) {
531
597
  ranges.push({ start: i, end: j, command: commandDecoded });
532
598
  }
533
599
  }
@@ -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.1";
7
+ export const KIMI_PLUGIN_CC_VERSION = "2.5.2";
@@ -23,6 +23,7 @@ Job controls are `/kimi-status [job-id]`, `/kimi-result [job-id]`, and `/kimi-ca
23
23
  - Node.js 24.9.0 or later.
24
24
  - DeepSeek Harness >=0.2.1-rc.5 <0.3.0; this package is developed and composed against 0.2.1-rc.5.
25
25
  - Kimi Code CLI on `PATH`, authenticated and configured through Kimi. The companion's exact-version certification table remains authoritative.
26
+ - A patched DSH host and profiles with zod >=4.6.6 and undici >=8.12.2; see [host installation and audit instructions](../../docs/registry-releases.md#patched-dsh-host-and-profiles). The plugin does not patch your host automatically.
26
27
 
27
28
  ## Install
28
29
 
@@ -34,6 +35,8 @@ dsh plugin --profile web add ./plugins/kimi-dsh
34
35
 
35
36
  For another DSH profile, replace `web` with its name. To install the published root package instead, run `dsh plugin --profile web add kimi-plugin-cc`.
36
37
 
38
+ For DSH Discover, enable **Custom plugin indexes** and add `https://raw.githubusercontent.com/linxule/kimi-plugin-cc/main/dsh/index.json` as an index source.
39
+
37
40
  Restart when requested, then run `/kimi-setup`. Setup is explicit: installing or enabling the bundle does not modify Kimi configuration. Delegation refuses to start when the DSH managed hook is missing or invalid.
38
41
 
39
42
  Useful setup commands:
@@ -61,7 +64,7 @@ The bundle's `kimi-plugin-cc` row accepts:
61
64
  |---|---|---|
62
65
  | `dataRoot` | `$DSH_HOME/plugin-data/kimi-plugin-cc` | Absolute companion data directory; `KIMI_PLUGIN_CC_DATA` overrides the derived default |
63
66
  | `env` | `{}` | Explicit child environment layered over DSH's credential-scrubbed environment |
64
- | `disposeGraceMs` | `6000` | Managed subprocess termination grace |
67
+ | `disposeGraceMs` | `6000` | Managed subprocess termination grace in milliseconds; minimum 6000 |
65
68
  | `maxOutputBytes` | `16777216` | Maximum captured stdout or stderr bytes per command |
66
69
  | `model` | Native Kimi default | Optional configured model alias applied to every delegation |
67
70
  | `thinkingEffort` | Native Kimi default | Optional per-run thinking effort request |