kimi-plugin-cc 2.4.1 → 2.4.3

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 (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +12 -12
  5. package/dist/cli-client.js +17 -7
  6. package/dist/commands/cancel.js +14 -3
  7. package/dist/commands/recover-swarm.js +135 -0
  8. package/dist/commands/status.js +1 -1
  9. package/dist/commands/swarm.js +51 -54
  10. package/dist/companion.js +5 -1
  11. package/dist/git.js +5 -3
  12. package/dist/help.js +3 -2
  13. package/dist/hooks/config-safety.js +3 -2
  14. package/dist/job-store.js +13 -0
  15. package/dist/jobs.js +3 -0
  16. package/dist/kimi-engine.js +1 -0
  17. package/dist/kimi-errors.js +27 -21
  18. package/dist/parsing.js +1 -0
  19. package/dist/prompt-size.js +18 -0
  20. package/dist/swarm-activation.js +24 -3
  21. package/dist/swarm-recovery.js +134 -0
  22. package/dist/version.js +1 -1
  23. package/docs/registry-releases.md +14 -1
  24. package/docs/safety.md +9 -7
  25. package/docs/swarm-recovery.md +33 -0
  26. package/docs/windows-validation.md +39 -0
  27. package/package.json +6 -3
  28. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  29. package/plugins/kimi-codex/dist/cli-client.js +17 -7
  30. package/plugins/kimi-codex/dist/commands/cancel.js +14 -3
  31. package/plugins/kimi-codex/dist/commands/recover-swarm.js +135 -0
  32. package/plugins/kimi-codex/dist/commands/status.js +1 -1
  33. package/plugins/kimi-codex/dist/commands/swarm.js +51 -54
  34. package/plugins/kimi-codex/dist/companion.js +5 -1
  35. package/plugins/kimi-codex/dist/git.js +5 -3
  36. package/plugins/kimi-codex/dist/help.js +3 -2
  37. package/plugins/kimi-codex/dist/hooks/config-safety.js +3 -2
  38. package/plugins/kimi-codex/dist/job-store.js +13 -0
  39. package/plugins/kimi-codex/dist/jobs.js +3 -0
  40. package/plugins/kimi-codex/dist/kimi-engine.js +1 -0
  41. package/plugins/kimi-codex/dist/kimi-errors.js +27 -21
  42. package/plugins/kimi-codex/dist/parsing.js +1 -0
  43. package/plugins/kimi-codex/dist/prompt-size.js +18 -0
  44. package/plugins/kimi-codex/dist/swarm-activation.js +24 -3
  45. package/plugins/kimi-codex/dist/swarm-recovery.js +134 -0
  46. package/plugins/kimi-codex/dist/version.js +1 -1
package/dist/git.js CHANGED
@@ -85,12 +85,13 @@ async function runGit(args, cwd) {
85
85
  }
86
86
  return result.stdout;
87
87
  }
88
- async function runGitAllowFailure(args, cwd) {
88
+ async function runGitAllowFailure(args, cwd, signal) {
89
89
  try {
90
90
  const { stdout, stderr } = await execFileAsync("git", args, {
91
91
  cwd,
92
92
  env: process.env,
93
93
  maxBuffer: 10 * 1024 * 1024,
94
+ signal,
94
95
  });
95
96
  return { ok: true, stdout, stderr };
96
97
  }
@@ -163,11 +164,11 @@ export async function createEphemeralWorktree(repoRoot, base, worktreePath) {
163
164
  *
164
165
  * Returns the patch text (empty string if the swarm changed nothing).
165
166
  */
166
- export async function captureWorktreePatch(worktreePath) {
167
+ export async function captureWorktreePatch(worktreePath, signal) {
167
168
  // Intent-to-add is required so untracked files surface in `git diff HEAD`.
168
169
  // An add -N failure aborts capture rather than returning a patch that omits
169
170
  // new files.
170
- const intentToAdd = await runGitAllowFailure(["add", "-N", "--", "."], worktreePath);
171
+ const intentToAdd = await runGitAllowFailure(["add", "-N", "--", "."], worktreePath, signal);
171
172
  if (!intentToAdd.ok) {
172
173
  throw new RuntimeError("GIT_WORKTREE_INTENT_TO_ADD_FAILED", `git add -N (worktree capture) failed: ${intentToAdd.stderr || "unknown error"}`, "swarm.worktree", { details: { worktreePath } });
173
174
  }
@@ -176,6 +177,7 @@ export async function captureWorktreePatch(worktreePath) {
176
177
  cwd: worktreePath,
177
178
  env: process.env,
178
179
  maxBuffer: MAX_PATCH_BYTES,
180
+ signal,
179
181
  });
180
182
  return stdout;
181
183
  }
package/dist/help.js CHANGED
@@ -1,5 +1,5 @@
1
- import { hasHelpFlag, ASK_SUPPORTED_FLAGS, RESCUE_SUPPORTED_FLAGS, REVIEW_SUPPORTED_FLAGS, PURSUE_SUPPORTED_FLAGS, SWARM_SUPPORTED_FLAGS, JOB_LOOKUP_SUPPORTED_FLAGS, SETUP_SUPPORTED_FLAGS, REPAIR_SESSIONS_SUPPORTED_FLAGS, REPLAY_SUPPORTED_FLAGS, } from "./parsing.js";
2
- const SUBCOMMANDS = "setup, review, task, ask, status, result, cancel, replay, repair-sessions";
1
+ import { hasHelpFlag, ASK_SUPPORTED_FLAGS, RESCUE_SUPPORTED_FLAGS, REVIEW_SUPPORTED_FLAGS, PURSUE_SUPPORTED_FLAGS, SWARM_SUPPORTED_FLAGS, JOB_LOOKUP_SUPPORTED_FLAGS, SETUP_SUPPORTED_FLAGS, REPAIR_SESSIONS_SUPPORTED_FLAGS, REPLAY_SUPPORTED_FLAGS, RECOVER_SWARM_SUPPORTED_FLAGS, } from "./parsing.js";
2
+ const SUBCOMMANDS = "setup, review, task, ask, status, result, cancel, replay, repair-sessions, recover-swarm";
3
3
  const TASK_KINDS = "rescue, challenge, pursue, swarm";
4
4
  /** Help is a dispatch-only, side-effect-free exception to normal argument parsing. */
5
5
  export function companionHelp(argv) {
@@ -33,6 +33,7 @@ export function companionHelp(argv) {
33
33
  case "cancel":
34
34
  return usage(command, "[job-id] [options]", JOB_LOOKUP_SUPPORTED_FLAGS, command === "status" ? "Show job state as JSON." : command === "result" ? "Read a terminal artifact, optionally in a JSON envelope." : "Cancel a running job; without an id, target the repository's latest running job.");
35
35
  case "replay": return usage(command, "<job-id>", REPLAY_SUPPORTED_FLAGS, "Replay a completed job's persisted stream log.");
36
+ case "recover-swarm": return `Usage: companion.sh recover-swarm <job-id> [${RECOVER_SWARM_SUPPORTED_FLAGS}]. Preview durable swarm recovery; --apply stops verified orphans, disables the recorded session and captures a patch. Never resumes prompts or changes terminal job outcomes.`;
36
37
  case "repair-sessions": return usage(command, "[options]", REPAIR_SESSIONS_SUPPORTED_FLAGS, "Preview session metadata repair; --apply writes, --all selects all host-owned repositories.");
37
38
  case "worker": return "Usage: companion.sh worker <ask|rescue> <job-id>. Internal detached-worker entry point; launched by ask/rescue, not manually.";
38
39
  case "mcp": return "Usage: companion.sh mcp. Run the Kimi MCP stdio server; normally launched by Claude Code or Codex plugin configuration.";
@@ -594,7 +594,7 @@ function validateVersionSensitiveEvents(events, version) {
594
594
  }
595
595
  // Exact schemas reviewed at 2.0.0 (1b89e4b0), 2.0.1 (caf7d4e2),
596
596
  // 2.0.2 (9d07f634), 2.1.0 (52437299), 2.1.1 (f67e6398), 2.2.0 (472192c3),
597
- // 2.3.0 (b5e28fd6), 2.3.1 (684bae9e) and 2.3.2 (b8dd3a13): the additive
597
+ // 2.3.0 (b5e28fd6), 2.3.1 (684bae9e) 2.3.2 (b8dd3a13), and 2.3.3 (ec495f0c): the additive
598
598
  // events and HookDefSchema are unchanged. Require the full version string AND matching components;
599
599
  // no inferred 2.x compatibility.
600
600
  if (version.major === 2 && ((version.minor === 0 && ((version.version === "2.0.0" && version.patch === 0) ||
@@ -605,7 +605,8 @@ function validateVersionSensitiveEvents(events, version) {
605
605
  (version.minor === 2 && ((version.version === "2.2.0" && version.patch === 0))) ||
606
606
  (version.minor === 3 && ((version.version === "2.3.0" && version.patch === 0) ||
607
607
  (version.version === "2.3.1" && version.patch === 1) ||
608
- (version.version === "2.3.2" && version.patch === 2))))) {
608
+ (version.version === "2.3.2" && version.patch === 2) ||
609
+ (version.version === "2.3.3" && version.patch === 3))))) {
609
610
  return { valid: true };
610
611
  }
611
612
  if (version.major !== 0) {
package/dist/job-store.js CHANGED
@@ -122,6 +122,8 @@ export class JobStore {
122
122
  CREATE INDEX IF NOT EXISTS jobs_repo_updated_idx
123
123
  ON jobs (repo_id, updated_at DESC);
124
124
 
125
+ CREATE TABLE IF NOT EXISTS swarm_recovery (job_id TEXT PRIMARY KEY, payload TEXT NOT NULL, recovery_owner TEXT);
126
+
125
127
  CREATE INDEX IF NOT EXISTS jobs_repo_type_updated_idx
126
128
  ON jobs (repo_id, command_type, updated_at DESC);
127
129
 
@@ -186,6 +188,17 @@ export class JobStore {
186
188
  throw translateSqliteError(error);
187
189
  }
188
190
  }
191
+ getSwarmRecovery(jobId) {
192
+ const row = this.db.get("SELECT payload, recovery_owner FROM swarm_recovery WHERE job_id = ?", jobId);
193
+ return row ? { record: JSON.parse(row.payload), owner: row.recovery_owner } : null;
194
+ }
195
+ saveSwarmRecovery(jobId, record) {
196
+ this.db.run("INSERT INTO swarm_recovery (job_id, payload) VALUES (?, ?) ON CONFLICT(job_id) DO UPDATE SET payload = excluded.payload", [jobId, JSON.stringify(record)]);
197
+ }
198
+ claimSwarmRecovery(jobId, expected, owner) {
199
+ this.db.run("UPDATE swarm_recovery SET recovery_owner = ? WHERE job_id = ? AND recovery_owner IS ?", [owner, jobId, expected]);
200
+ return this.db.get("SELECT changes() AS count").count === 1;
201
+ }
189
202
  close() {
190
203
  this.db.close();
191
204
  }
package/dist/jobs.js CHANGED
@@ -85,6 +85,9 @@ export async function sweepStaleJobs(store, paths) {
85
85
  for (const job of store.listRunningJobsWithProcessHints()) {
86
86
  if (store.getMcpTask(job.job_id))
87
87
  continue;
88
+ // Recovery owns orphaned swarm resources; PID-only sweeping must not signal replacements.
89
+ if (store.getSwarmRecovery(job.job_id))
90
+ continue;
88
91
  if (!isStaleEnoughToSweep(job)) {
89
92
  continue;
90
93
  }
@@ -42,6 +42,7 @@ export const NATIVE_V2_CERTIFIED_VERSIONS = Object.freeze([
42
42
  "2.3.0",
43
43
  "2.3.1",
44
44
  "2.3.2",
45
+ "2.3.3",
45
46
  ]);
46
47
  /** Per-operation native-v2 certification. One green operation never certifies another. */
47
48
  export const NATIVE_V2_CERTIFIED = new Map([
@@ -44,6 +44,11 @@ export function classifyManagedCommandFailure(error, commandType, jobId, options
44
44
  const details = { availability: classification.kind };
45
45
  if (error instanceof RuntimeError) {
46
46
  details.cause_code = error.code;
47
+ for (const key of ["spawn_code", "spawn_errno", "spawn_message"]) {
48
+ if (error.details[key] !== undefined)
49
+ details[key] = key === "spawn_message" && typeof error.details[key] === "string"
50
+ ? truncateCauseMessageForDetails(error.details[key]) : error.details[key];
51
+ }
47
52
  if (error.code === "RESPONSE_TIMEOUT") {
48
53
  for (const key of ["budget_ms", "record_count", "partial_output_chars", "aborted", "settled_post_abort", "hook_escapes"]) {
49
54
  if (error.details[key] !== undefined)
@@ -129,28 +134,29 @@ function classifyKimiAvailability(error) {
129
134
  authProbe: "failed",
130
135
  };
131
136
  }
132
- if (error instanceof RuntimeError && error.code === "CLI_SPAWN_FAILED") {
133
- return {
134
- kind: "binary_unavailable",
135
- summary: "the Kimi CLI is missing from PATH or not executable in this environment.",
136
- nextStep: "Run `/kimi:setup` to verify the install, then expose `kimi` on PATH and retry.",
137
- runtimeProbe: "failed",
138
- authProbe: "failed",
139
- };
140
- }
141
- // v1.0 cli-client surfaces async ENOENT (Bun) as CLI_PROCESS_ERROR with
142
- // "spawn ... ENOENT" in the message. Map that to binary_unavailable so
143
- // the failure message points users at /kimi:setup.
144
137
  if (error instanceof RuntimeError &&
145
- error.code === "CLI_PROCESS_ERROR" &&
146
- /\bENOENT\b/.test(message)) {
147
- return {
148
- kind: "binary_unavailable",
149
- summary: "the Kimi CLI is missing from PATH or not executable in this environment.",
150
- nextStep: "Run `/kimi:setup` to verify the install, then expose `kimi` on PATH and retry.",
151
- runtimeProbe: "failed",
152
- authProbe: "failed",
153
- };
138
+ (error.code === "CLI_SPAWN_FAILED" || error.code === "CLI_PROCESS_ERROR")) {
139
+ const spawnCode = error.details.spawn_code;
140
+ const binaryUnavailable = typeof spawnCode === "string" && ["ENOENT", "EACCES", "ENOEXEC"].includes(spawnCode);
141
+ if (typeof spawnCode === "string" && !binaryUnavailable) {
142
+ return {
143
+ kind: "startup_failed",
144
+ summary: "the Kimi subprocess could not be started or used.",
145
+ nextStep: "Check the underlying process error; shorten the prompt or environment if an argument-size limit was exceeded, then retry.",
146
+ runtimeProbe: "failed",
147
+ authProbe: "failed",
148
+ };
149
+ }
150
+ // Keep opaque synchronous failures and legacy async ENOENT diagnostics compatible.
151
+ if (binaryUnavailable || error.code === "CLI_SPAWN_FAILED" || /\bENOENT\b/.test(message)) {
152
+ return {
153
+ kind: "binary_unavailable",
154
+ summary: "the Kimi CLI is missing from PATH or not executable in this environment.",
155
+ nextStep: "Run `/kimi:setup` to verify the install, then expose `kimi` on PATH and retry.",
156
+ runtimeProbe: "failed",
157
+ authProbe: "failed",
158
+ };
159
+ }
154
160
  }
155
161
  // v1.0 cli-client surfaces non-ENOENT process errors and non-zero
156
162
  // exits as CLI_PROCESS_ERROR / CLI_NONZERO_EXIT. Treat both as
package/dist/parsing.js CHANGED
@@ -18,6 +18,7 @@ export const RESCUE_SUPPORTED_FLAGS = "-m/--model <name>, --thinking <off|low|me
18
18
  export const REVIEW_SUPPORTED_FLAGS = "--base <ref>, -m/--model <name>, --thinking <off|low|medium|high>, --budget <30m|1h|90s>";
19
19
  export const JOB_LOOKUP_SUPPORTED_FLAGS = "--type <review|challenge|rescue|review_gate|ask>, --json";
20
20
  export const SETUP_SUPPORTED_FLAGS = "--check, --uninstall, --all (with --uninstall), --enable-review-gate, --disable-review-gate, --models [--json] (without other setup flags)";
21
+ export const RECOVER_SWARM_SUPPORTED_FLAGS = "--apply";
21
22
  export const REPAIR_SESSIONS_SUPPORTED_FLAGS = "--apply, --all";
22
23
  export const REPLAY_SUPPORTED_FLAGS = "none (one job-id required)";
23
24
  /** Validate syntax only; upstream per-model supportEfforts remains authoritative. */
@@ -0,0 +1,18 @@
1
+ import { RuntimeError } from "./errors.js";
2
+ // Node/libuv's default CreateProcess argument quoting (UTF-16 code units).
3
+ export function windowsCommandLineLength(command, args) {
4
+ const quotedLength = (value) => {
5
+ if (value.length > 0 && !/[\s"]/u.test(value))
6
+ return value.length;
7
+ return 2 + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1').length;
8
+ };
9
+ return [command, ...args].reduce((size, value) => size + quotedLength(value) + 1, 0);
10
+ }
11
+ export function assertPromptFitsCommandLine(command, args, platform = process.platform) {
12
+ if (platform !== "win32")
13
+ return;
14
+ const size = windowsCommandLineLength(command, args);
15
+ if (size <= 32_767)
16
+ return;
17
+ throw new RuntimeError("CLI_COMMAND_LINE_TOO_LONG", "The Kimi prompt exceeds the Windows command-line limit; the CLI was not started. Narrow the review or split it into smaller scopes. A short ask prompt can reference a saved diff inside the workspace. Reinstalling Kimi or running setup will not fix this. The current print transport does not support stdin prompts.", "cli-client.pre-spawn", { details: { command_line_utf16_units: size, limit_utf16_units: 32_767, platform } });
18
+ }
@@ -9,7 +9,7 @@ import { assertExecutionPlanMatchesSpawn, assertNoUnsafeExperimentalSelector, }
9
9
  import { resolveKimiHome } from "./kimi-home.js";
10
10
  import { assertNativeV2Preflight } from "./native-v2-preflight.js";
11
11
  import { collectDescendantIdentities, collectProcessGroupsIdentities, probeProcessIdentity, revalidateProcessIdentities, waitForProcessTreeExit, } from "./process-tree.js";
12
- const PREACTIVATION_VERSION = "2.3.2";
12
+ const PREACTIVATION_VERSIONS = ["2.3.2", "2.3.3"];
13
13
  const READY_TIMEOUT_MS = 10_000;
14
14
  const REST_TIMEOUT_MS = 5_000;
15
15
  const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -25,7 +25,7 @@ const ESCALATION_MS = 1_500;
25
25
  const POST_KILL_QUIESCENCE_MS = 500;
26
26
  const ENV_MODEL_ALIAS = "__kimi_env_model__";
27
27
  export function requiresSwarmPreactivation(version) {
28
- return version === PREACTIVATION_VERSION;
28
+ return version !== null && PREACTIVATION_VERSIONS.includes(version);
29
29
  }
30
30
  export async function preactivateSwarmSession(options) {
31
31
  const failureCode = "SWARM_ACTIVATION_FAILED";
@@ -39,6 +39,7 @@ export async function preactivateSwarmSession(options) {
39
39
  await verifyMeta(web, prepared.expectedVersion, options.signal, failureCode);
40
40
  const model = await resolveProfileModel(web, options, failureCode);
41
41
  identity = await createSession(web, options, failureCode);
42
+ options.onRecoveryEvent?.({ kind: "session", ...identity });
42
43
  profileMutationAttempted = true;
43
44
  await postSwarmProfile(web, identity, true, model, options.signal, failureCode);
44
45
  await verifySwarmStatus(web, identity, true, model, options.signal, failureCode);
@@ -88,6 +89,7 @@ export async function deactivateSwarmSession(options) {
88
89
  await verifySwarmStatus(web, options, false, undefined, options.signal, failureCode);
89
90
  await shutdownWeb(web, options.signal, failureCode);
90
91
  await requireSwarmJournal(prepared.kimiHome, options, "exit", options.signal, failureCode);
92
+ options.onRecoveryEvent?.({ kind: "disabled" });
91
93
  throwIfAborted(options.signal, failureCode, "swarm-deactivation.complete");
92
94
  }
93
95
  catch (error) {
@@ -114,7 +116,7 @@ async function prepareWebSpawn(options, failureCode) {
114
116
  }
115
117
  const expectedVersion = options.expectedVersion ?? options.executionPlan.kimiVersion;
116
118
  if (expectedVersion === null || !requiresSwarmPreactivation(expectedVersion)) {
117
- throw failure(failureCode, "swarm-activation.version", `Swarm web preactivation requires exact kimi-code ${PREACTIVATION_VERSION}.`);
119
+ throw failure(failureCode, "swarm-activation.version", `Swarm web preactivation requires exact kimi-code ${PREACTIVATION_VERSIONS.join(" or ")}.`);
118
120
  }
119
121
  const kimiHome = resolveKimiHome(options.env, options.cwd);
120
122
  if (options.executionPlan.intendedEngine === "native-v2") {
@@ -144,6 +146,7 @@ async function startWebChild(options, env, failureCode) {
144
146
  "error",
145
147
  ];
146
148
  let child;
149
+ options.onRecoveryEvent?.({ kind: "web-spawn" });
147
150
  try {
148
151
  child = spawn(options.command, args, {
149
152
  cwd: options.cwd,
@@ -153,6 +156,7 @@ async function startWebChild(options, env, failureCode) {
153
156
  });
154
157
  }
155
158
  catch {
159
+ options.onRecoveryEvent?.({ kind: "web-exit" });
156
160
  throw failure(failureCode, "swarm-activation.spawn", "Failed to spawn the Kimi web lifecycle process.");
157
161
  }
158
162
  let exited = false;
@@ -184,6 +188,7 @@ async function startWebChild(options, env, failureCode) {
184
188
  child,
185
189
  closePromise,
186
190
  descendants: [],
191
+ onRecoveryEvent: options.onRecoveryEvent,
187
192
  trackingStopped: false,
188
193
  lastSnapshotComplete: false,
189
194
  snapshotIncomplete: false,
@@ -198,6 +203,8 @@ async function startWebChild(options, env, failureCode) {
198
203
  try {
199
204
  const ready = await waitForReadyLine(owned, options.signal, failureCode);
200
205
  await refreshOwnedDescendants(owned);
206
+ if (owned.recoveryFailed)
207
+ throw failure(failureCode, "swarm-activation.recovery", "Cannot persist web ownership; refusing session mutation.");
201
208
  return Object.assign(owned, ready);
202
209
  }
203
210
  catch (error) {
@@ -610,12 +617,26 @@ async function refreshOwnedDescendants(owned) {
610
617
  owned.snapshotIncomplete = true;
611
618
  owned.lastSnapshotComplete = false;
612
619
  }
620
+ try {
621
+ owned.onRecoveryEvent?.({ kind: "web-processes", identities: [...(owned.rootIdentity ? [owned.rootIdentity] : []), ...owned.descendants], complete: owned.lastSnapshotComplete });
622
+ }
623
+ catch {
624
+ owned.recoveryFailed = true;
625
+ }
613
626
  })().finally(() => {
614
627
  owned.refreshPromise = undefined;
615
628
  });
616
629
  return await owned.refreshPromise;
617
630
  }
618
631
  async function teardownOwnedChild(owned) {
632
+ const quiescent = await teardownOwnedChildInner(owned);
633
+ if (quiescent && !owned.recoveryFailed) {
634
+ owned.onRecoveryEvent?.({ kind: "web-exit" });
635
+ return true;
636
+ }
637
+ return false;
638
+ }
639
+ async function teardownOwnedChildInner(owned) {
619
640
  await stopDescendantTracking(owned);
620
641
  const childPid = owned.child.pid;
621
642
  if (childPid === undefined)
@@ -0,0 +1,134 @@
1
+ import { RuntimeError } from "./errors.js";
2
+ import { collectDescendantIdentities, collectProcessGroupsIdentities, probeProcessIdentity, revalidateProcessIdentities } from "./process-tree.js";
3
+ export function recordRecoveryEvent(store, record, event) {
4
+ record.complete = false;
5
+ if (event.kind === "session") {
6
+ record.session = { sessionId: event.sessionId, workspaceId: event.workspaceId };
7
+ // Write-ahead: this must commit before the profile mutation is attempted.
8
+ record.sessionDirty = true;
9
+ }
10
+ else if (event.kind === "disabled") {
11
+ record.sessionDirty = false;
12
+ }
13
+ else if (event.kind.endsWith("-spawn")) {
14
+ record.launches.push({ kind: event.kind === "web-spawn" ? "web" : "print", pending: true, identities: [], quiescent: false, uncertain: false });
15
+ }
16
+ else {
17
+ const kind = event.kind.startsWith("web-") ? "web" : "print";
18
+ const launch = [...record.launches].reverse().find((entry) => entry.kind === kind);
19
+ if (!launch)
20
+ throw recoveryError("Missing durable launch intent.");
21
+ if (event.kind === "web-exit" || event.kind === "print-exit") {
22
+ launch.quiescent = true;
23
+ launch.pending = false;
24
+ }
25
+ else if ("identities" in event) {
26
+ const all = [...launch.identities, ...event.identities];
27
+ launch.identities = [...new Map(all.map((identity) => [`${identity.pid}:${identity.startToken}:${identity.processGroupId}`, identity])).values()];
28
+ launch.pending = launch.identities.length === 0;
29
+ launch.uncertain ||= !event.complete;
30
+ }
31
+ }
32
+ store.saveSwarmRecovery(record.jobId, record);
33
+ }
34
+ export function finishRecoveryRecord(store, record) {
35
+ record.complete = !record.sessionDirty && record.launches.every((entry) => entry.quiescent)
36
+ && (record.workspaceRoot === null || record.patchPath !== null);
37
+ store.saveSwarmRecovery(record.jobId, record);
38
+ }
39
+ export function recoveryError(message) {
40
+ return new RuntimeError("SWARM_RECOVERY_REQUIRED", message, "swarm.recovery");
41
+ }
42
+ export async function assertRecoveryOwnerGone(record) {
43
+ if (!record.owner || process.platform === "win32")
44
+ throw recoveryError("No durable, verifiable owner identity; recovery requires manual inspection on this host.");
45
+ const probe = await probeProcessIdentity(record.ownerPid);
46
+ if (!probe.complete)
47
+ throw recoveryError("Cannot verify the original companion's identity.");
48
+ if (probe.identity?.startToken === record.owner.startToken && !/^[ZX]/i.test(probe.identity.state)) {
49
+ throw recoveryError("The original companion is still alive. Use cancel for a running job, not recovery.");
50
+ }
51
+ }
52
+ /**
53
+ * Linux may fall back from /proc to ps for a whole-group snapshot while targeted
54
+ * identity reads still use /proc. Those start tokens are different namespaces,
55
+ * not different processes. Prove both representations current around the known
56
+ * identity read; never accept a member based on PID/PGID equality alone.
57
+ */
58
+ export async function validateRecoveryGroupMembers(known, members, validate = revalidateProcessIdentities) {
59
+ const membership = await validate(members);
60
+ const ownership = await validate(known);
61
+ const membershipAfter = await validate(membership.identities);
62
+ for (const proof of [membership, ownership, membershipAfter]) {
63
+ if (!proof.complete || proof.groupChanged?.length)
64
+ throw recoveryError("Process ownership or group membership is uncertain.");
65
+ }
66
+ for (const member of membershipAfter.identities) {
67
+ const owner = ownership.identities.find((id) => id.pid === member.pid && id.processGroupId === member.processGroupId);
68
+ const crossNamespace = owner && ((owner.startToken.startsWith("proc:") && member.startToken.startsWith("ps:")) ||
69
+ (owner.startToken.startsWith("ps:") && member.startToken.startsWith("proc:")));
70
+ if (!owner || (owner.startToken !== member.startToken && !crossNamespace)) {
71
+ throw recoveryError("An unrecorded process survives in the recorded group; refusing group cleanup.");
72
+ }
73
+ }
74
+ return ownership.identities;
75
+ }
76
+ /** Never signal an unproven group, PID replacement, or changed-PGID process. */
77
+ export async function recoverLaunch(launch, persist, signal) {
78
+ if (launch.quiescent)
79
+ return;
80
+ if (launch.pending || launch.uncertain || launch.identities.length === 0) {
81
+ throw recoveryError("Launch ownership was not fully recorded before interruption; manual inspection is required. No process was signalled.");
82
+ }
83
+ const root = launch.identities[0];
84
+ const rootProbe = await probeProcessIdentity(root.pid);
85
+ if (!rootProbe.complete)
86
+ throw recoveryError("Process identity probe is incomplete.");
87
+ if (rootProbe.identity?.startToken === root.startToken) {
88
+ if (rootProbe.identity.processGroupId !== root.processGroupId)
89
+ throw recoveryError("The recorded root changed process groups; refusing to signal it.");
90
+ const descendants = await collectDescendantIdentities(root.pid);
91
+ // Revalidate the ancestry anchor after collection before retaining new identities.
92
+ const after = await probeProcessIdentity(root.pid);
93
+ if (!after.complete || after.identity?.startToken !== root.startToken || after.identity.processGroupId !== root.processGroupId) {
94
+ throw recoveryError("Process ownership changed during recovery inspection.");
95
+ }
96
+ launch.identities = [...new Map([...launch.identities, ...descendants].map((id) => [`${id.pid}:${id.startToken}:${id.processGroupId}`, id])).values()];
97
+ persist();
98
+ }
99
+ const inspect = async () => {
100
+ const members = await collectProcessGroupsIdentities(launch.identities.map((id) => id.processGroupId));
101
+ if (members === undefined)
102
+ throw recoveryError("Process group membership is unreadable.");
103
+ return validateRecoveryGroupMembers(launch.identities, members);
104
+ };
105
+ for (const [killSignal, waitMs] of [["SIGTERM", 1500], ["SIGKILL", 500]]) {
106
+ signal?.throwIfAborted();
107
+ for (const identity of await inspect()) {
108
+ signal?.throwIfAborted();
109
+ const current = await revalidateProcessIdentities([identity]);
110
+ if (!current.complete || current.groupChanged?.length)
111
+ throw recoveryError("Process identity changed before signalling.");
112
+ if (current.identities.length) {
113
+ try {
114
+ process.kill(identity.pid, killSignal);
115
+ }
116
+ catch (error) {
117
+ if (error.code !== "ESRCH")
118
+ throw recoveryError("Unable to stop the recorded process.");
119
+ }
120
+ }
121
+ }
122
+ const deadline = Date.now() + waitMs;
123
+ do {
124
+ signal?.throwIfAborted();
125
+ if ((await inspect()).length === 0) {
126
+ launch.quiescent = true;
127
+ persist();
128
+ return;
129
+ }
130
+ await new Promise((resolve) => setTimeout(resolve, 25));
131
+ } while (Date.now() < deadline);
132
+ }
133
+ throw recoveryError("Recorded processes did not become quiescent; recovery remains pending.");
134
+ }
package/dist/version.js CHANGED
@@ -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.4.1";
7
+ export const KIMI_PLUGIN_CC_VERSION = "2.4.3";
@@ -6,7 +6,20 @@ remain available. The package has no install scripts and no separate CLI.
6
6
 
7
7
  ## Publishing status
8
8
 
9
- Plugin **2.4.1** certifies exact CLI **2.3.2** for all eight operations with host-preactivated swarm sessions. Its [certification evidence](./upstream-2.3.2-certification.md) includes the source scan, plan controls, hardened 15-test/115-assertion candidate matrix, and final 19-test/125-assertion production smoke in 296.67 seconds. Independent review is complete: Claude reports no blockers and Codex returned RESOLVED. The final repository gate passed 1,268 tests with zero failures and 16,055 assertions across 78 files, and package validation passed 224 files. CI, tagging, and registry verification are pending; the currently published release remains 2.4.0 until that closeout completes.
9
+ Plugin **2.4.3** adds exact CLI **2.3.3** certification for all eight operations,
10
+ with the existing host-preactivated swarm lifecycle and durable recovery.
11
+ See the [certification record](./upstream-2.3.3-certification.md).
12
+ Release execution and registry verification are recorded in the release notes;
13
+ publication uses the same validated-tarball OIDC workflow below.
14
+
15
+ Plugin **2.4.2** adds durable interrupted-swarm recovery and Windows spawn diagnostics.
16
+ Exact CLI certification remains unchanged through **2.3.2**; this is not a new
17
+ Windows-support or large-prompt-transport claim. See the [release](https://github.com/linxule/kimi-plugin-cc/releases/tag/v2.4.2),
18
+ [recovery guide](./swarm-recovery.md), and [Windows community controls](./windows-validation.md).
19
+ Publication uses the same validated-tarball OIDC workflow below; release execution
20
+ and registry verification are recorded in the release notes.
21
+
22
+ Plugin **2.4.1** certifies exact CLI **2.3.2** for all eight operations with host-preactivated swarm sessions. [Release v2.4.1](https://github.com/linxule/kimi-plugin-cc/releases/tag/v2.4.1) is anchored at `06a0a47f2b34dffb8dd71e7ce93646212e74ed19`; its [CI check](https://github.com/linxule/kimi-plugin-cc/actions/runs/40236704464) and [release-event publishing run](https://github.com/linxule/kimi-plugin-cc/actions/runs/40237085945) passed. The registry tarball has SHA-1 `77fe6daaa0f23750cc9b5d857bdbdd5107a7d4ce`, contains 224 files, and matches the CI artifact byte-for-byte. Fresh installs from the CI artifact and npm registry both passed isolated setup/check for Claude Code and Codex. The earlier CI attempt 40231073897 exposed an intermittent root-exit cleanup issue; the root-continuity fix and independent review landed before tagging. Final closeout CI run 40238295139 passed.
10
23
 
11
24
  Plugin **2.4.0** preserves exact CLI **2.3.1** certification and adds the shared MCP transport. [Release v2.4.0](https://github.com/linxule/kimi-plugin-cc/releases/tag/v2.4.0) is anchored at `9232d1ec98465454d8d9328e439d3f31bfc53e04`; its [CI check](https://github.com/linxule/kimi-plugin-cc/actions/runs/40040084944) and [release-event publishing run](https://github.com/linxule/kimi-plugin-cc/actions/runs/40040442610) passed. The registry tarball has SHA-1 `8243616529aa9f03f26bf4fef68c36d90e2304ae`, contains 222 files, and matches the CI artifact byte-for-byte. A fresh npm install passed isolated setup/check for both hosts.
12
25
  The previous verified publication was **2.3.1**:
package/docs/safety.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This guide describes the current checkout runtime. Read-only tasks deny file writes and shell commands. Rescue and pursue can edit your workspace. Write-swarm returns a patch from a temporary Git worktree.
4
4
 
5
- Native agent-core-v2 is certified at exact CLI versions `0.42.0`, `0.43.0`, `0.43.1`, `2.0.0`, `2.0.1`, `2.0.2`, `2.1.0`, `2.1.1`, `2.2.0`, `2.3.0`, `2.3.1`, and `2.3.2`, with plan mode disabled. The [runtime certification table](../runtime/kimi-engine.ts) controls routing. The hook is not an operating-system sandbox.
5
+ Native agent-core-v2 is certified at exact CLI versions `0.42.0`, `0.43.0`, `0.43.1`, `2.0.0`, `2.0.1`, `2.0.2`, `2.1.0`, `2.1.1`, `2.2.0`, `2.3.0`, `2.3.1`, `2.3.2`, and `2.3.3`, with plan mode disabled. The [runtime certification table](../runtime/kimi-engine.ts) controls routing. The hook is not an operating-system sandbox.
6
6
 
7
7
  ## Why the safety story is hook-based
8
8
 
@@ -205,11 +205,13 @@ If another installed host still points to an older hook, that hook may veto thes
205
205
  - **Resume is intentionally not exposed yet.** In goal mode `goal.summary` emits a *goalId* distinct from the resume-hint *sessionId*; `kimi -r <sessionId>` re-enters the session but not necessarily the goal continuation. The plugin captures and surfaces the goalId but does not offer `--resume` for pursue until that split is reconciled upstream — exposing it now would be a silent-failure trap. **Re-verified against kimi-code 0.26.0 (2026-07-16):** the headless CLI still has no argv, prompt syntax, or session-resume path that calls `resumeGoal()`. `parseHeadlessGoalCreate` (`apps/kimi-code/src/cli/goal-prompt.ts`) accepts only the `/goal <objective>` *create* shape on both the v1 and experimental-v2 drivers, and upstream's own docs (`docs/en/guides/goals.md`, § "Use goal mode carefully") state that in non-interactive prompt mode *only goal creation is supported* — goal management is a TUI/kimi-web control. PR #552 makes a plain `-r <sessionId>` correctly *restore* the paused goal snapshot, but nothing in the headless path *acts* on it; the real `resumeGoal()` is wired only to the TUI (`/goal resume`) and kimi-web RPC (PR #1693, web-only). Implementing `--resume` would therefore require either prompt-injected model compliance (rejected — the plugin bounds autonomy with hard limits, not model goodwill) or abandoning the subprocess model for kimi-web's RPC. Re-check if upstream ever ships a headless `goal resume` verb.
206
206
  - **A model-invocable `kimi-pursue` agent (v1.3) wraps this command.** The main thread can dispatch autonomous goal mode, not just the human-typed `/kimi:pursue` — the same command↔agent mirror as rescue/review/swarm. Auto-dispatch does NOT widen the blast radius: pursue runs under `commandLabel: "rescue"`, so the managed PreToolUse hook + workspace allowlist gate every tool call on every continuation turn (no git mutation), and the mandatory `--budget` wall-clock ceiling bounds the autonomy regardless of who launched it. Because it is write-capable AND autonomous — the plugin's highest blast radius — the agent's description requires an explicit user request for hands-off multi-turn pursuit, steers single bounded tasks to `kimi-rescue`, and never auto-promotes a fix or a question into a goal loop. Like the command, it refuses without the hook and offers no `--resume`.
207
207
 
208
- ## Swarm activation on CLI 2.3.2
208
+ ## Swarm activation on CLI 2.3.2 and 2.3.3
209
+
210
+ For interrupted runs, see [durable recovery and its explicit limits](swarm-recovery.md).
209
211
 
210
212
  Upstream 2.3.2 removes model self-activation of `AgentSwarm` in fresh print sessions. A rejected call can therefore coexist with zero child journals and a successful exit; a tool-call record, aggregate denial, or coordinator-produced patch does not prove fan-out. The plugin does not use prompt workarounds or replace its print transport with general RPC.
211
213
 
212
- For versions requiring activation (currently exact 2.3.2), both slash-command and MCP swarm operations use the same `runSwarm` implementation and one ordinary `kimi -p -r` prompt runner per job, bracketed by short-lived host-managed web lifecycles:
214
+ For versions requiring activation (currently exact 2.3.2 and 2.3.3), both slash-command and MCP swarm operations use the same `runSwarm` implementation and one ordinary `kimi -p -r` prompt runner per job, bracketed by short-lived host-managed web lifecycles:
213
215
 
214
216
  1. The exact planned CLI starts `web --no-open --host 127.0.0.1 --port 0 --log-level error`. The plugin accepts only the expected loopback ready URL, uses a supplied token for authenticated REST requests, and keeps that token in memory, never writing it to logs. `/api/v1/meta` must confirm backend v2, the exact planned version, and no dangerous authentication bypass. The plugin uses the explicit model or environment-model alias, otherwise reading the default alias from `/api/v1/config`; creates a session in the job cwd; binds that model with `permission_mode:auto` and `swarm_mode:true`; verifies those settings and `plan_mode:false` through `/status`; verifies clean shutdown; and requires the main journal's manual `swarm_mode.enter` record.
215
217
  2. The ordinary stream-json print runner resumes that session with `kimi -p -r`. The job records `kimi_session_id_source:"preactivated"`, and the session returned by print must exactly match the preactivated id. Every coordinator and child tool call remains governed by the same managed PreToolUse hook and operation allowlist.
@@ -222,7 +224,7 @@ Older certified versions keep the direct `kimi -p` path. The web processes are n
222
224
  `/kimi:swarm` (v1.2) fans a **read-only** review out across subagents using kimi-code's `AgentSwarm` tool (kimi-code 0.12.0+). The coordinator launches one subagent per target (file/module/question); each inspects the workspace with read tools and reports findings, which the coordinator consolidates into one markdown report. It opens **zero new write surface**.
223
225
 
224
226
  - **The hook label is `swarm`, and it allows the read-only tool set PLUS `AgentSwarm`.** The coordinator must be allowed to call `AgentSwarm` (else the swarm never launches), but no write/edit/shell tool is allowed. The exact allowed tool name is `AgentSwarm` (verified at 0.42.0 against `packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts`: `'AgentSwarm'`, strict schema). The singular `Agent` tool is deliberately *not* allowed — swarm is the fan-out surface, not arbitrary delegation.
225
- - **Every subagent inherits the `swarm` label and fires the same hook.** On native v2 (0.42.0) `AgentSwarm` children run in-process in a child DI scope with their OWN eager external-hooks service, so every child tool call passes the same before-execute channel and the same managed hook — there is no sub-vs-main branch and no deny-all shortcut on the swarm route. So a subagent's `Write`/`Edit`/`Bash` is denied exactly like a single-turn review's. The swarm listener in that channel is non-terminal and nothing auto-approves `AgentSwarm` ahead of the hook. (Legacy-v1 ≤ 0.41 reached the same result by placing the hook policy at index 0 of every agent's permission stack.) **Required for final certification evidence** (`tests/runtime/real-binary-smoke.test.ts`, run via `bun run smoke:real`): distinct child journals must exist, and each assigned Write/Edit `path` must be correlated through its `toolCallId` with the managed denial in that exact tool result; unrelated denials and coordinator-only reports are insufficient. On 2.3.2, targeted controls also prove that direct OFF produces no children while the preactivated ON path produces the real child journals and denials.
227
+ - **Every subagent inherits the `swarm` label and fires the same hook.** On native v2 (0.42.0) `AgentSwarm` children run in-process in a child DI scope with their OWN eager external-hooks service, so every child tool call passes the same before-execute channel and the same managed hook — there is no sub-vs-main branch and no deny-all shortcut on the swarm route. So a subagent's `Write`/`Edit`/`Bash` is denied exactly like a single-turn review's. The swarm listener in that channel is non-terminal and nothing auto-approves `AgentSwarm` ahead of the hook. (Legacy-v1 ≤ 0.41 reached the same result by placing the hook policy at index 0 of every agent's permission stack.) **Required for final certification evidence** (`tests/runtime/real-binary-smoke.test.ts`, run via `bun run smoke:real`): distinct child journals must exist, and each assigned Write/Edit `path` must be correlated through its `toolCallId` with the managed denial in that exact tool result; unrelated denials and coordinator-only reports are insufficient. On 2.3.2 and 2.3.3, targeted controls also prove that direct OFF produces no children while the preactivated ON path produces the real child journals and denials.
226
228
  - **The managed hook is the read-only boundary; no `explore`-profile second layer is assumed.** Production deliberately omits `subagent_type` for read-only swarm rather than selecting kimi-code's `explore` profile. Every coordinator and subagent tool call is enforced by the same managed hook.
227
229
  - **Swarm refuses without the hook, like every model-spawning command.** A swarm makes the reason especially acute: it asks for up to `--cap` subagents, but this total-count hint is advisory, so a missing hook would remove enforcement from all of them at once. `KIMI_PLUGIN_CC_SKIP_HOOK_CHECK=1` remains an explicit tests/diagnostics escape hatch, not a recovery path.
228
230
  - **The only new risk is COST, and the plugin bounds it.** Read-only swarm writes nothing, so the new risk is runaway cost from N parallel model runs (and possible nested `AgentSwarm`). `/kimi:swarm` enforces a **mandatory finite wall-clock ceiling** via its AbortController (`--budget`, default 30m); the SIGTERM→SIGKILL descendant-reaping cancellation reaps the whole subagent process tree. Two **distinct** subagent bounds: `--cap N` is a *soft* prompt-injected hint on TOTAL subagent count (the hook is stateless and cannot enforce a count, so the model may exceed it), and `--max-concurrency N` is a *hard* ceiling on how many subagents kimi-code's `SubagentBatch` runs concurrently — exported as `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` (PR #888) on **kimi-code 0.18.0+** (older binaries ignore the unknown env var, so on < 0.18 only the soft hint applies). It **defaults to 4** when the user passes none (`resolveSwarmMaxConcurrency` in swarm.ts), so every swarm run — agent-dispatched or human-typed — carries an enforced peak-parallelism ceiling on 0.18.0+ rather than falling back to kimi-code's internal ramp; pass an explicit value to widen or throttle. **Never merge them into one flag:** concurrency ≠ total count — a hard concurrency cap of N still permits more than N subagents over the life of the run, just never more than N at once. The `--budget` bounds elapsed time, not a dollar amount or total token count; `--max-concurrency` bounds *peak* parallelism (the spike in simultaneous model spend), and `--cap` is only an advisory total-count nudge. Folding both onto one flag would create the "cap-as-total-count illusion" — a user reading `--cap N` would expect a total bound but receive only a peak-parallelism one.
@@ -236,7 +238,7 @@ Older certified versions keep the direct `kimi -p` path. The web processes are n
236
238
  - **Writes are confined by the hook to a forge-proof trusted root.** The `swarm-write` hook label routes write/edit/shell through the **rescue allowlist verbatim** (workspace check, symlink/realpath reject, mutating-flag + exec-delegating-flag detector, git-mutation deny), but scoped to `KIMI_PLUGIN_CC_WORKSPACE_ROOT` — the worktree path the plugin exports on the spawn — **NOT** the hook payload `cwd`. The model running inside kimi cannot set an env var on its already-spawned process, so confinement does not depend on how upstream derives the payload cwd for an in-process subagent. A missing trusted root **fails closed** (all writes denied). An out-of-worktree write (absolute path into the user's real tree) is rejected by the allowlist's `isWithin` check; a write to the worktree's `.git` pointer is rejected by the `.git`-segment check.
237
239
  - **`coder` subagents really can write, and the hook is the sole boundary.** Verified at kimi-code 0.42.0: swarm subagents get their own eager external-hooks service under the standard before-execute channel — there is no deny-all path on the swarm route (the `btw` side-question path is separate). Write-swarm's `coder` subagents DO have edit tools, and the managed PreToolUse hook is the **only** write boundary — which is why the trusted-root confinement above is load-bearing and gated tightly.
238
240
  - **The plugin never touches git; the main thread owns the merge.** Subagents cannot run git (allowlist denies mutation). The plugin captures the change set with `git add -N` (intent-to-add, so new files appear in the diff WITHOUT writing loose objects into the shared object DB) + `git diff --binary` (untruncated, applyable), writes it to a `swarm-write-<jobId>.patch` artifact, and prints the path. It does **not** `git apply`, commit, or move any ref — the user/main thread reviews the patch and applies it (`git apply <path>`). The worktree shares the main repo's object DB and refs, but no ref/HEAD is ever moved, so the user's repository state is untouched.
239
- - **The patch is captured on every terminal path, before cleanup.** Success, cancel, budget-expiry, and failure all capture the (possibly partial) patch BEFORE `git worktree remove --force` discards the tree, so a cancelled run never silently loses the work the subagents already did. If capture itself fails, the job fails instead of completing and the worktree is retained with its path in the error details for recovery. That guarantee covers the plugin's own terminal paths, including `companion.sh cancel`. It does **not** cover a harness interrupt: Esc/TaskStop delivers SIGTERM then SIGKILL after ~1.35s (measured, process-group-wide), which is shorter than the 1500ms child escalation plus quiescence plus patch capture — so a hard interrupt can kill the companion mid-teardown and lose the patch. `companion.sh cancel` signals from a separate process not racing that deadline and is the work-preserving stop. A startup sweep reaps worktrees orphaned by a hard kill (SIGKILL of the plugin process); its reap TTL is derived as `MAX_DURATION_MS + 2h` so it always exceeds the longest a run can live (the 24h `--budget` cap) and can never force-remove a still-live worktree out from under a concurrent run — the sweep's only liveness signal is the worktree dir mtime, which a run editing only existing files leaves frozen at `git worktree add` time.
241
+ - **The patch is captured on every terminal path, before cleanup.** Success, cancel, budget-expiry, and failure all capture the (possibly partial) patch BEFORE `git worktree remove --force` discards the tree, so a cancelled run never silently loses the work the subagents already did. If capture itself fails, the job fails instead of completing and the worktree is retained with its path in the error details for recovery. That guarantee covers the plugin's own terminal paths, including `companion.sh cancel`. It does **not** cover a harness interrupt: Esc/TaskStop delivers SIGTERM then SIGKILL after ~1.35s (measured, process-group-wide), which is shorter than the 1500ms child escalation plus quiescence plus patch capture — so a hard interrupt can kill the companion mid-teardown and lose the patch. `companion.sh cancel` signals from a separate process not racing that deadline and is the work-preserving stop. Abandoned worktrees are retained, never age-deleted. Use the preview/apply recovery procedure in [swarm-recovery.md](swarm-recovery.md); uncertain ownership refuses automatic cleanup.
240
242
  - **Gated tightly.** Requires kimi-code **≥ 0.18.0** (below it `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` is ignored, so a write fan-out would have no hard peak bound — unacceptable for concurrent writers); because that cap cannot be guaranteed without a confirmed version, the write-mode version gate **fails closed** on an unconfirmable `kimi --version` probe (the shared execution-plan gate also refuses unverified or uncertified binaries for read-only swarm; older certified legacy versions may lack the hard concurrency setting). Also requires a git repo with a **committed HEAD**, and the PreToolUse hook (**refuses** without it, like read swarm). Bases the worktree on HEAD, so a **loud warning** fires when the working tree is dirty (uncommitted work is not visible to the swarm). Default `--max-concurrency` is **1** (serialize) since disjoint-target partitioning is prompt-only and unenforceable by the stateless hook; the coordinator prompt also forbids git mutation and nested `AgentSwarm`.
241
243
  - **Required for final certification evidence.** `tests/runtime/real-binary-smoke.test.ts` runs the real `runSwarm --write` and requires the POSITIVE end-to-end: distinct child journals contain their assigned edits with `Write`/`Replaced` results on the same `toolCallId`, those child-authored changes land in the worktree and captured patch, the user's real tree is byte-identical + git-clean, and the worktree is cleaned up. A patch without per-call child provenance is not accepted as fan-out evidence. The NEGATIVE lane correlates each absolute out-of-root `path` and `toolCallId` with its managed denial. It is the check that catches `tool_input` field-name drift — `Write`/`Edit` carry `path`, not `file_path`, and reading the wrong key denies every real write silently.
242
244
  - **Model-invocable agent (v1.5), patch-only by construction.** Following the field-proof above, `--write` now has a `kimi-swarm-write` subagent so the main thread can dispatch a write fan-out on its own judgement — the same human-command + model-invocable-agent split the rest of the suite uses. Auto-dispatch is a safe extension here because it widens **no** write surface and removes **no** bound: the agent path runs the identical `runSwarm --write` code, so edits still land only in the throwaway worktree, the managed hook still gates every `coder` subagent, the patch is still handed back for the user to apply (**the plugin never applies or commits**), and `--budget` + default `--max-concurrency` 1 + the hook requirement all still hold. In fact write-swarm is a *less* dangerous agent than the `kimi-pursue` agent already shipped: pursue writes directly into the workspace, whereas write-swarm's output is a patch the human reviews before anything reaches a real tree. Because it is still a write surface, the agent description demands BOTH signals — many disjoint write targets AND explicit fan-out intent — and steers single edits to `kimi-rescue`, read-only fan-outs to `kimi-swarm`, and autonomous loops to `kimi-pursue`. Default `--max-concurrency` stays **1**; raising it (concurrent writers) remains a deliberate, human-or-explicitly-requested choice.
@@ -291,9 +293,9 @@ at it (the runtime resolves that path instead of `kimi` on `PATH`; every spawn
291
293
  still exports `KIMI_CODE_NO_AUTO_UPDATE=1`, so the pinned copy never drifts).
292
294
 
293
295
  ```sh
294
- PIN=~/.local/share/kimi-plugin-cc/kimi-bin/2.3.2
296
+ PIN=~/.local/share/kimi-plugin-cc/kimi-bin/2.3.3
295
297
  mkdir -p "$PIN" && cd "$PIN" && printf '{"name":"kimi-plugin-cc-pin","private":true}\n' > package.json
296
- bun add @moonshot-ai/kimi-code@2.3.2
298
+ npm install @moonshot-ai/kimi-code@2.3.3
297
299
  export KIMI_PLUGIN_CC_KIMI_BIN="$PIN/node_modules/.bin/kimi" # e.g. in ~/.zshenv
298
300
  ```
299
301
 
@@ -0,0 +1,33 @@
1
+ # Recovering an interrupted swarm
2
+
3
+ Normal stop: use `scripts/companion.sh cancel` for a shell-owned run, or the MCP job cancel tool for an MCP-owned run. A hard kill cannot execute cleanup. Cancellation still starts no fresh cleanup server.
4
+
5
+ For exact CLI 2.3.2 and 2.3.3 swarm runs created by this runtime, status includes a durable `swarm_recovery` record. It contains the resolved home/workspace, companion identity, launch intent, verified process identities and session identity. It never contains the web authentication token, credentials, prompt or config contents. Session identity is committed before the request enabling swarm mode.
6
+
7
+ ## Preview, then recover
8
+
9
+ Run from the original repository with its original plugin data root and Kimi home:
10
+
11
+ ```sh
12
+ scripts/companion.sh recover-swarm <job-id>
13
+ scripts/companion.sh recover-swarm <job-id> --apply
14
+ ```
15
+
16
+ Preview does not signal processes, modify sessions, or capture patches. Apply:
17
+
18
+ 1. Requires the original companion to be gone and acquires an identity-checked recovery claim. A second recovery cannot race the first; a dead recovery owner can be reclaimed by compare-and-swap.
19
+ 2. Revalidates recorded processes and groups. It stops only verified individual identities, never a guessed PID or unproven group. An unknown surviving group member, changed group, incomplete probe or unrecorded launch refuses recovery.
20
+ 3. If session cleanup is pending, requires the selected exact certified CLI and valid managed hook. It starts one bounded authenticated web lifecycle, disables the recorded session's swarm mode, verifies status and the exit journal, then verifies process quiescence. It does not resume a prompt, generate model output, install hooks or retry failed cleanup automatically. New recovery web launches are themselves journaled.
21
+ 4. Captures a binary patch from a retained write worktree. It does not apply the patch or delete the tree. The result names both paths for inspection.
22
+
23
+ The recovery attempt has a 30-second cancellation signal, with bounded identity probes and normal teardown. Interrupting recovery retains its journal. An already-recovered record is an idempotent no-op. Failed/cancelled/completed job outcomes are immutable; a previously running abandoned job becomes failed, never completed. Recovery status is separate from task success.
24
+
25
+ ## Limits and manual inspection
26
+
27
+ - SIGKILL between process creation and durable identity capture leaves an explicit pending launch. Apply refuses to guess ownership. A supervisor/OS-owned launch primitive would be required to eliminate this remaining crash window; the journal does not claim that guarantee.
28
+ - No recovery record means no automatic recovery: older runs, or a failure before the record was created, require inspection. Recovery is not general-purpose process or native-session cleanup.
29
+ - Windows does not currently provide the required durable process-identity proof. Preview is available; apply refuses. This does not change existing Windows direct-child cancellation limits.
30
+ - A changed home, missing/redirected worktree, CLI mismatch, invalid hook or uncertain identity leaves evidence intact and reports failure. Do not repair this by bypassing the hook or killing processes by name.
31
+ - Abandoned worktrees are no longer age-deleted. Inspect and recover them before explicitly removing them. This also protects legacy orphaned trees without a recovery record.
32
+
33
+ The trust boundary is the same local, host-owned plugin database used for execution plans. Recovery does not make an attacker-modified database trustworthy. Internal Git capture retains the repository-configuration trust boundary documented in [safety.md](safety.md).