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
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Kimi for Claude Code: code questions, reviews, fixes, parallel tasks, and MCP tasks through the local Kimi Code CLI.",
8
- "version": "2.4.1"
8
+ "version": "2.4.3"
9
9
  },
10
10
  "plugins": [
11
11
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kimi",
3
3
  "description": "Kimi for Claude Code: code questions, independent reviews, bounded fixes, autonomous goals, parallel tasks, and MCP tasks through the local Kimi Code CLI, with PreToolUse safety hooks.",
4
- "version": "2.4.1",
4
+ "version": "2.4.3",
5
5
  "homepage": "https://github.com/linxule/kimi-plugin-cc",
6
6
  "repository": "https://github.com/linxule/kimi-plugin-cc",
7
7
  "license": "Apache-2.0",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  > **Post-1.0 release history (v1.0.1 -> present) lives in [ROADMAP-TO-GA.md § Post-GA audit log](./ROADMAP-TO-GA.md#post-ga-audit-log)** and the "Version" / "Upstream compat" lines of [AGENTS.md](./AGENTS.md). Docs-only kimi-code compat checkups that don't bump the plugin version (e.g. the 0.14.2 / 0.14.3 patches) are recorded there, not here. Notable releases are summarized below; the GA entry and full pre-GA detail follow.
4
4
 
5
+ ## 2.4.3 — 2026-10-27
6
+
7
+ - Certify exact kimi-code 2.3.3 for all eight native-v2 operations after exact-source review, plan and swarm controls, and candidate/production smoke. See [evidence](docs/upstream-2.3.3-certification.md).
8
+ - Add only the reviewed 2.3.3 activation and hook-schema entries. Preserve no-plan enforcement, workspace confinement, concurrency, bounded cleanup and durable recovery; legacy-v1 remains capped at 0.41.x.
9
+ - Expand the compatibility audit to cover the web activation server explicitly, and refresh current compatibility/status documentation without changing installed hosts.
10
+
11
+ ## 2.4.2 — 2026-10-26
12
+
13
+ - Integrate community PR #22's structured spawn-error classification, preserving contributor history. Bound the additional diagnostic message. Known argument/resource exhaustion no longer receives reinstall advice.
14
+ - Refuse oversized Windows command lines before spawn and add a credential-free compiled-runtime Windows CI lane. This improves diagnosis, not prompt transport; issue #14 remains open for native Windows/community verification and large-review support.
15
+ - Add durable, preview-first `recover-swarm` for exact 2.3.2 interrupted runs, with verified process ownership, pre-mutation session identity, recovery locking, explicit uncertainty refusals and patch preservation. Stop age-based deletion of abandoned worktrees. Exact CLI certification remains unchanged through 2.3.2.
16
+ - Shorten the agent contract sheet and enforce its 32 KiB limit during `check`.
17
+
18
+ Thanks to **@Nitjsefnie** for PR #22.
19
+
5
20
  ## 2.4.1 — 2026-10-25
6
21
 
7
22
  - Certify exact kimi-code 2.3.2 for all eight operations using the 15-test/92-assertion source scan, plan-ON/OFF controls, and the final 19-test/125-assertion production smoke. Keep the no-plan construction and legacy-v1 boundary at 0.41.x. See [evidence](docs/upstream-2.3.2-certification.md).
package/README.md CHANGED
@@ -40,9 +40,9 @@ You need:
40
40
  - macOS or Linux
41
41
  - Claude Code or Codex with plugin support
42
42
  - Node.js 22.5 or newer
43
- - Kimi Code CLI 2.3.2 (certified; 2.3.1 and 2.3.0 are also certified)
43
+ - Kimi Code CLI 2.3.3 (certified; 2.3.2, 2.3.1 and 2.3.0 are also certified)
44
44
 
45
- Windows is not currently supported. Check your versions in a terminal:
45
+ Windows is not currently supported. Contributors can help with the [no-model Windows diagnostics checks](./docs/windows-validation.md). Check your versions in a terminal:
46
46
 
47
47
  ```sh
48
48
  node --version
@@ -105,10 +105,10 @@ Codex. Published releases are also available as
105
105
  both compiled plugin distributions. No Bun, TypeScript, or install scripts are
106
106
  needed to use the package. Kimi Code CLI and Node are still prerequisites.
107
107
 
108
- For an explicitly versioned local Claude Code plugin, this example pins the published 2.4.0 package; 2.4.1 is not yet published:
108
+ For an explicitly versioned local Claude Code plugin, this example pins the 2.4.3 package:
109
109
 
110
110
  ```sh
111
- npm install --prefix ~/.local/share/kimi-plugin-cc kimi-plugin-cc@2.4.0
111
+ npm install --prefix ~/.local/share/kimi-plugin-cc kimi-plugin-cc@2.4.3
112
112
  claude --plugin-dir ~/.local/share/kimi-plugin-cc/node_modules/kimi-plugin-cc
113
113
  ```
114
114
 
@@ -174,7 +174,7 @@ Use these commands to follow a task:
174
174
  | Read the result | `/kimi:result` | `$kimi-result` |
175
175
  | Cancel work | `/kimi:cancel` | `$kimi-cancel` |
176
176
 
177
- Without a job ID, cancel stops the latest running task for this repository. If several tasks are running, check which one you want to stop first. Use cancel rather than interrupting the app so write-swarm can save its patch.
177
+ Without a job ID, cancel stops the latest running task for this repository. If several tasks are running, check which one you want to stop first. Use cancel rather than interrupting the app so write-swarm can save its patch. For interrupted runs created by the unreleased recovery implementation, see [preview-first swarm recovery](./docs/swarm-recovery.md); it never applies a patch or deletes abandoned work.
178
178
 
179
179
  Canceling a task does not undo changes it has already made.
180
180
 
@@ -228,7 +228,7 @@ All refusal codes and their remedies are listed in the [refusal-code catalog](./
228
228
 
229
229
  The tool rules for ask, review, challenge, and read-only swarm deny file writes and shell commands. Rescue and pursue can write files in your workspace. Swarm with `--write` works in a temporary Git worktree. It starts from your latest commit, so it does not include uncommitted changes. It returns a patch without applying it to your project.
230
230
 
231
- For swarm on CLI 2.3.2, the plugin briefly starts an authenticated loopback `kimi web` process to activate a session and another to deactivate swarm mode afterward; one ordinary `kimi -p -r` process still performs the task. Activation counts against the time limit, the access token stays in memory and out of logs, and cleanup failure prevents success. Older certified versions use `kimi -p` directly.
231
+ For swarm on CLI 2.3.2 and 2.3.3, the plugin briefly starts an authenticated loopback `kimi web` process to activate a session and another to deactivate swarm mode afterward; one ordinary `kimi -p -r` process still performs the task. Activation counts against the time limit, the access token stays in memory and out of logs, and cleanup failure prevents success. Older certified versions use `kimi -p` directly.
232
232
 
233
233
  The plugin does not commit changes. Review changes before you keep or apply them.
234
234
 
@@ -264,7 +264,7 @@ Contributors need Bun to run `bun run check`. The detailed engineering documents
264
264
  - macOS 或 Linux
265
265
  - 支持插件的 Claude Code 或 Codex
266
266
  - Node.js 22.5 或更高版本
267
- - Kimi Code CLI 2.3.2(已认证;2.3.1 和 2.3.0 也已认证)
267
+ - Kimi Code CLI 2.3.3(已认证;2.3.2、2.3.1 和 2.3.0 也已认证)
268
268
 
269
269
  目前不支持 Windows。在终端检查版本:
270
270
 
@@ -427,7 +427,7 @@ codex plugin add kimi@kimi-marketplace
427
427
 
428
428
  Ask、review、challenge 和只读 swarm 的工具规则禁止写入文件和执行 shell 命令。Rescue 和 pursue 可以修改工作区内的文件。带 `--write` 的 swarm 在临时 Git 工作树中操作。它从最新提交开始,不包含尚未提交的修改。任务结束后返回补丁,不会自动应用到项目。
429
429
 
430
- 在 CLI 2.3.2 上运行 swarm 时,插件会短暂启动需要认证的本地回环 `kimi web` 进程来激活会话,并在结束后再次启动以停用 swarm 模式;任务本身仍由一个普通的 `kimi -p -r` 进程执行。激活计入任务时限,访问令牌只保存在内存且不会写入日志;无法确认清理完成时,任务不会报告成功。较早的已认证版本仍直接通过 `kimi -p` 运行。
430
+ 在 CLI 2.3.2 和 2.3.3 上运行 swarm 时,插件会短暂启动需要认证的本地回环 `kimi web` 进程来激活会话,并在结束后再次启动以停用 swarm 模式;任务本身仍由一个普通的 `kimi -p -r` 进程执行。激活计入任务时限,访问令牌只保存在内存且不会写入日志;无法确认清理完成时,任务不会报告成功。较早的已认证版本仍直接通过 `kimi -p` 运行。
431
431
 
432
432
  插件不会提交修改。保留或应用修改前,请先审查。
433
433
 
@@ -463,7 +463,7 @@ Il vous faut :
463
463
  - macOS ou Linux
464
464
  - une version de Claude Code ou de Codex prenant en charge les plugins
465
465
  - Node.js 22.5 ou une version ultérieure
466
- - Kimi Code CLI 2.3.2 (certifiée ; les versions 2.3.1 et 2.3.0 sont également certifiées)
466
+ - Kimi Code CLI 2.3.3 (certifiée ; les versions 2.3.2, 2.3.1 et 2.3.0 sont également certifiées)
467
467
 
468
468
  Windows n'est pas pris en charge actuellement. Vérifiez les versions dans un terminal :
469
469
 
@@ -626,7 +626,7 @@ Tous les codes de refus et leurs remèdes sont répertoriés dans le [catalogue
626
626
 
627
627
  Les règles des modes ask, review, challenge et swarm en lecture seule interdisent les écritures et les commandes shell. Rescue et pursue peuvent modifier les fichiers de votre espace de travail. Swarm avec `--write` travaille dans un worktree Git temporaire. Il part du dernier commit et n'inclut donc pas les modifications non commitées. Il renvoie un patch sans l'appliquer à votre projet.
628
628
 
629
- Avec le CLI 2.3.2, les tâches swarm lancent brièvement un processus `kimi web` authentifié sur l'interface de boucle locale pour activer la session, puis un second pour désactiver le mode swarm ; un seul processus `kimi -p -r` ordinaire exécute toujours la tâche. L'activation est comptée dans la limite de temps, le jeton reste en mémoire et hors des journaux, et l'échec du nettoyage empêche de déclarer la réussite. Les versions certifiées antérieures utilisent directement `kimi -p`.
629
+ Avec le CLI 2.3.2 et 2.3.3, les tâches swarm lancent brièvement un processus `kimi web` authentifié sur l'interface de boucle locale pour activer la session, puis un second pour désactiver le mode swarm ; un seul processus `kimi -p -r` ordinaire exécute toujours la tâche. L'activation est comptée dans la limite de temps, le jeton reste en mémoire et hors des journaux, et l'échec du nettoyage empêche de déclarer la réussite. Les versions certifiées antérieures utilisent directement `kimi -p`.
630
630
 
631
631
  Le plugin ne crée aucun commit. Examinez les modifications avant de les conserver ou de les appliquer.
632
632
 
@@ -662,7 +662,7 @@ Les contributeurs ont besoin de Bun pour exécuter `bun run check`. La documenta
662
662
  - macOS または Linux
663
663
  - プラグインに対応した Claude Code または Codex
664
664
  - Node.js 22.5 以降
665
- - Kimi Code CLI 2.3.2(認証済み。2.3.1 と 2.3.0 も認証済み)
665
+ - Kimi Code CLI 2.3.3(認証済み。2.3.2、2.3.1 と 2.3.0 も認証済み)
666
666
 
667
667
  現在、Windows には対応していません。ターミナルでバージョンを確認します。
668
668
 
@@ -825,7 +825,7 @@ codex plugin add kimi@kimi-marketplace
825
825
 
826
826
  Ask、review、challenge、読み取り専用 swarm のツールルールでは、ファイルへの書き込みとシェルコマンドを禁止しています。Rescue と pursue はワークスペース内のファイルを変更できます。`--write` を指定した swarm は、一時的な Git ワークツリーで作業します。最新のコミットから開始するため、未コミットの変更は含まれません。パッチを返すだけで、プロジェクトには自動適用しません。
827
827
 
828
- CLI 2.3.2 の swarm では、セッションを有効化するため、そして終了後に swarm モードを無効化するため、認証付きのローカルループバック `kimi web` プロセスをそれぞれ短時間だけ起動します。タスク自体は従来どおり 1 つの `kimi -p -r` プロセスが実行します。有効化も制限時間に含まれ、トークンはメモリー内にのみ保持されてログには出ません。後処理を確認できない場合、タスクは成功と報告されません。以前の認証済みバージョンでは、引き続き `kimi -p` を直接使います。
828
+ CLI 2.3.2 / 2.3.3 の swarm では、セッションを有効化するため、そして終了後に swarm モードを無効化するため、認証付きのローカルループバック `kimi web` プロセスをそれぞれ短時間だけ起動します。タスク自体は従来どおり 1 つの `kimi -p -r` プロセスが実行します。有効化も制限時間に含まれ、トークンはメモリー内にのみ保持されてログには出ません。後処理を確認できない場合、タスクは成功と報告されません。以前の認証済みバージョンでは、引き続き `kimi -p` を直接使います。
829
829
 
830
830
  プラグインは変更をコミットしません。変更を残す、または適用する前に内容を確認してください。
831
831
 
@@ -7,6 +7,7 @@
7
7
  import { spawn } from "node:child_process";
8
8
  import { appendFile, mkdir } from "node:fs/promises";
9
9
  import path from "node:path";
10
+ import { assertPromptFitsCommandLine } from "./prompt-size.js";
10
11
  import { createHookEscapeMonitor } from "./hook-escape.js";
11
12
  import { RuntimeError, formatError } from "./errors.js";
12
13
  import { assertNoUnsafeExperimentalSelector, assertExecutionPlanMatchesSpawn, } from "./kimi-engine.js";
@@ -29,6 +30,14 @@ const DESCENDANT_SNAPSHOT_TIMEOUT_MS = 1_000;
29
30
  /** Hard bound for each batched identity revalidation pass. */
30
31
  const IDENTITY_REVALIDATION_TIMEOUT_MS = 250;
31
32
  const DESCENDANT_SNAPSHOT_TIMEOUT = Symbol("descendant-snapshot-timeout");
33
+ function spawnErrorDetails(error) {
34
+ const cause = error;
35
+ return {
36
+ spawn_code: cause?.code,
37
+ spawn_errno: cause?.errno,
38
+ spawn_message: formatError(error),
39
+ };
40
+ }
32
41
  export async function runCliPrompt(opts) {
33
42
  // Pre-aborted signal: refuse to spawn at all.
34
43
  if (opts.signal?.aborted === true) {
@@ -60,6 +69,7 @@ export async function runCliPrompt(opts) {
60
69
  });
61
70
  }
62
71
  const args = buildArgs(opts);
72
+ assertPromptFitsCommandLine(opts.command, args);
63
73
  const env = buildKimiCliEnvironment(opts);
64
74
  if (opts.logPath !== undefined) {
65
75
  await mkdir(path.dirname(opts.logPath), { recursive: true }).catch(() => {
@@ -82,8 +92,8 @@ export async function runCliPrompt(opts) {
82
92
  }
83
93
  catch (err) {
84
94
  throw new RuntimeError("CLI_SPAWN_FAILED", `Failed to spawn kimi: ${formatError(err)}`, "cli-client.spawn", err instanceof Error
85
- ? { cause: err, details: { command: opts.command, args } }
86
- : { details: { command: opts.command, args } });
95
+ ? { cause: err, details: { command: opts.command, args, ...spawnErrorDetails(err) } }
96
+ : { details: { command: opts.command, args, ...spawnErrorDetails(err) } });
87
97
  }
88
98
  // Pre-attach an 'error' listener immediately. Bun (and Node) can emit
89
99
  // 'error' asynchronously between spawn returning and our Promise body
@@ -550,8 +560,8 @@ export async function runCliPrompt(opts) {
550
560
  child.on("error", (err) => {
551
561
  appendLogLine({ event: "process_error", message: formatError(err) });
552
562
  settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess error: ${formatError(err)}`, "cli-client.process", err instanceof Error
553
- ? { cause: err, details: errorDetails() }
554
- : { details: errorDetails() }));
563
+ ? { cause: err, details: { ...errorDetails(), ...spawnErrorDetails(err) } }
564
+ : { details: { ...errorDetails(), ...spawnErrorDetails(err) } }));
555
565
  });
556
566
  if (earlyErrors.length > 0) {
557
567
  const captured = earlyErrors[0];
@@ -562,7 +572,7 @@ export async function runCliPrompt(opts) {
562
572
  early: true,
563
573
  additional_count: earlyErrors.length - 1,
564
574
  });
565
- settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess error: ${formatError(captured)}`, "cli-client.process", { cause: captured, details: errorDetails() }));
575
+ settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess error: ${formatError(captured)}`, "cli-client.process", { cause: captured, details: { ...errorDetails(), ...spawnErrorDetails(captured) } }));
566
576
  });
567
577
  }
568
578
  child.on("close", async (exitCode, signal) => {
@@ -643,8 +653,8 @@ export async function runCliPrompt(opts) {
643
653
  }
644
654
  catch (err) {
645
655
  settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess stream setup failed: ${formatError(err)}`, "cli-client.streams", err instanceof Error
646
- ? { cause: err, details: errorDetails() }
647
- : { details: errorDetails() }));
656
+ ? { cause: err, details: { ...errorDetails(), ...spawnErrorDetails(err) } }
657
+ : { details: { ...errorDetails(), ...spawnErrorDetails(err) } }));
648
658
  }
649
659
  });
650
660
  }
@@ -36,6 +36,14 @@ export async function runCancel(argv, context) {
36
36
  (context.mcpCompanionIdentity === undefined || context.mcpKimiIdentity === undefined)) {
37
37
  throw new RuntimeError("MCP_CANCEL_REQUIRES_BINDING", `MCP job ${job.job_id} requires MCP job-control cancellation with recorded identities; use tasks/cancel or kimi_job_cancel.`, "mcp.cancel");
38
38
  }
39
+ const recovery = store.getSwarmRecovery(job.job_id)?.record;
40
+ if (recovery) {
41
+ const identity = recovery.owner;
42
+ const verified = identity && identity.pid === job.pid ? await revalidateProcessIdentities([identity]) : undefined;
43
+ if (!verified?.complete || verified.groupChanged?.length || verified.identities.length !== 1) {
44
+ throw new RuntimeError("SWARM_RECOVERY_REQUIRED", `Recorded swarm ownership is no longer live or verifiable. Preview companion.sh recover-swarm ${job.job_id}; no process was signalled.`, "cancel.identity");
45
+ }
46
+ }
39
47
  if (job.pid === null && job.kimi_pid === null) {
40
48
  throw new RuntimeError("CANCEL_NOT_SUPPORTED", `Job ${job.job_id} does not have a recorded process id to cancel.`, "cancel.runtime");
41
49
  }
@@ -75,7 +83,10 @@ export async function runCancel(argv, context) {
75
83
  }
76
84
  await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity);
77
85
  await assertMcpKimiIdentity(job, context.mcpKimiIdentity);
78
- signalJobProcesses(job, "SIGTERM");
86
+ if (recovery)
87
+ await assertMcpCompanionIdentity(job, recovery.owner);
88
+ // The live swarm owner owns child teardown, including web phases after print exit.
89
+ signalJobProcesses(job, "SIGTERM", { companion: true, kimi: !recovery });
79
90
  const cancelled = await waitForCancellation(paths, job.job_id);
80
91
  if (cancelled) {
81
92
  return `${JSON.stringify({
@@ -84,10 +95,10 @@ export async function runCancel(argv, context) {
84
95
  message: "Cancellation completed.",
85
96
  }, null, 2)}\n`;
86
97
  }
87
- const companionMatches = await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity, true);
98
+ const companionMatches = await assertMcpCompanionIdentity(job, recovery ? recovery.owner : context.mcpCompanionIdentity, true);
88
99
  const kimiMatches = await assertMcpKimiIdentity(job, context.mcpKimiIdentity, true);
89
100
  if (companionMatches || kimiMatches) {
90
- signalJobProcesses(job, "SIGKILL", { companion: companionMatches, kimi: kimiMatches });
101
+ signalJobProcesses(job, "SIGKILL", { companion: companionMatches, kimi: !recovery && kimiMatches });
91
102
  }
92
103
  await withJobStore(paths, async (forcedStore) => {
93
104
  const current = forcedStore.getJob(job.job_id);
@@ -0,0 +1,135 @@
1
+ import { RECOVER_SWARM_SUPPORTED_FLAGS } from "../parsing.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, realpath, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { createCliCancellationHandlers } from "../cli-cancellation.js";
6
+ import { RuntimeError } from "../errors.js";
7
+ import { captureWorktreePatch, resolveRepoIdentity } from "../git.js";
8
+ import { verifyHookInstalled } from "../hooks/install.js";
9
+ import { markJobFailed } from "../jobs.js";
10
+ import { JobStore } from "../job-store.js";
11
+ import { executionPlanFromPersisted, prepareKimiExecutionPlan } from "../kimi-engine.js";
12
+ import { resolveKimiHome } from "../kimi-home.js";
13
+ import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
14
+ import { probeProcessIdentity } from "../process-tree.js";
15
+ import { deactivateSwarmSession } from "../swarm-activation.js";
16
+ import { assertRecoveryOwnerGone, finishRecoveryRecord, recordRecoveryEvent, recoverLaunch, recoveryError } from "../swarm-recovery.js";
17
+ export async function runRecoverSwarm(argv, context) {
18
+ const positional = argv.filter((arg) => !arg.startsWith("-"));
19
+ if (positional.length !== 1 || argv.some((arg) => arg.startsWith("-") && arg !== RECOVER_SWARM_SUPPORTED_FLAGS) || argv.filter((arg) => arg === RECOVER_SWARM_SUPPORTED_FLAGS).length > 1) {
20
+ throw new RuntimeError("INVALID_ARGS", `Usage: companion.sh recover-swarm <job-id> [${RECOVER_SWARM_SUPPORTED_FLAGS}]. Defaults to preview.`, "swarm.recovery.args");
21
+ }
22
+ const jobId = positional[0];
23
+ const apply = argv.includes(RECOVER_SWARM_SUPPORTED_FLAGS);
24
+ const paths = resolvePluginPaths(context.env);
25
+ await ensurePluginPaths(paths);
26
+ const repo = await resolveRepoIdentity(context.cwd);
27
+ const store = new JobStore(paths);
28
+ let claim;
29
+ const handlers = createCliCancellationHandlers();
30
+ const signal = AbortSignal.any([handlers.signal, AbortSignal.timeout(30_000)]);
31
+ try {
32
+ const job = store.getJob(jobId);
33
+ const saved = store.getSwarmRecovery(jobId);
34
+ if (!job || job.repo_id !== repo.repoId || !saved || saved.record.schema !== 1 || saved.record.jobId !== jobId) {
35
+ throw recoveryError("No recovery record owned by this repository. Older runs require manual inspection.");
36
+ }
37
+ let record = saved.record;
38
+ const summary = () => ({ job_id: jobId, mode: apply ? "apply" : "preview", recovery_complete: record.complete,
39
+ session_id: record.session?.sessionId ?? null, session_cleanup_pending: record.sessionDirty,
40
+ launches: record.launches, worktree: record.workspaceRoot, patch_path: record.patchPath,
41
+ next_step: record.complete ? "No recovery is needed. Retained worktrees may be inspected and removed manually."
42
+ : `Inspect this record; use companion.sh recover-swarm ${jobId} --apply only after the original job has stopped.` });
43
+ const settleAbandonedJob = async () => {
44
+ if (job.status === "running")
45
+ await markJobFailed(store, paths, job, new RuntimeError("SWARM_OWNER_DISAPPEARED", "The original swarm companion stopped without reporting a result; resource recovery completed, not the task.", "swarm.recovery"), "Abandoned swarm resources recovered; task did not complete.", { phase: "failed" });
46
+ };
47
+ if (!apply)
48
+ return `${JSON.stringify(summary(), null, 2)}\n`;
49
+ if (record.complete) {
50
+ if (job.status === "running") {
51
+ await assertRecoveryOwnerGone(record);
52
+ await settleAbandonedJob();
53
+ }
54
+ return `${JSON.stringify(summary(), null, 2)}\n`;
55
+ }
56
+ await assertRecoveryOwnerGone(record);
57
+ if (saved.owner !== null) {
58
+ const owner = JSON.parse(saved.owner);
59
+ const live = await probeProcessIdentity(owner.pid);
60
+ if (!live.complete || live.identity?.startToken === owner.startToken)
61
+ throw recoveryError("Another recovery owner is alive or cannot be verified.");
62
+ }
63
+ const self = await probeProcessIdentity(process.pid);
64
+ if (!self.complete || !self.identity)
65
+ throw recoveryError("Cannot establish recovery ownership on this host.");
66
+ claim = JSON.stringify(self.identity);
67
+ if (!store.claimSwarmRecovery(jobId, saved.owner, claim)) {
68
+ claim = undefined;
69
+ throw recoveryError("Another recovery acquired this job.");
70
+ }
71
+ // Another owner may have completed and released the claim between our first read
72
+ // and the CAS (null -> owner -> null). Never overwrite that newer journal.
73
+ record = store.getSwarmRecovery(jobId).record;
74
+ if (record.complete) {
75
+ await settleAbandonedJob();
76
+ return `${JSON.stringify(summary(), null, 2)}\n`;
77
+ }
78
+ const expectedRoot = job.operation_kind === "swarm-write"
79
+ ? path.join(paths.worktreesDir, job.repo_id, `swarm-write-${job.job_id}`) : null;
80
+ if (record.workspaceRoot !== expectedRoot || record.cwd !== (expectedRoot ?? job.cwd)
81
+ || record.kimiHome !== resolveKimiHome(context.env, record.cwd)) {
82
+ throw recoveryError("Recovery workspace or Kimi home differs from the recorded job. Use its original home and repository; do not guess a session.");
83
+ }
84
+ if (expectedRoot) {
85
+ const st = await lstat(expectedRoot);
86
+ if (!st.isDirectory() || st.isSymbolicLink())
87
+ throw recoveryError("Recovery worktree must be the original non-symlink directory.");
88
+ const parent = await realpath(path.dirname(expectedRoot));
89
+ if (await realpath(expectedRoot) !== path.join(parent, path.basename(expectedRoot)))
90
+ throw recoveryError("Recovery worktree path is redirected.");
91
+ }
92
+ const persist = () => store.saveSwarmRecovery(jobId, record);
93
+ for (const launch of record.launches)
94
+ await recoverLaunch(launch, persist, signal);
95
+ if (record.sessionDirty) {
96
+ if (!record.session || !/^[\w-]+$/.test(record.session.sessionId) || !/^[\w-]+$/.test(record.session.workspaceId))
97
+ throw recoveryError("Recovery session identity is missing or invalid.");
98
+ const installed = await verifyHookInstalled(context.env);
99
+ if (!installed.installed)
100
+ throw recoveryError(`Managed hook verification failed: ${installed.reason ?? "unknown"}. Recovery does not install or relax hooks.`);
101
+ const plan = executionPlanFromPersisted(job);
102
+ const current = await prepareKimiExecutionPlan({ operationKind: plan.operationKind, cwd: record.cwd, env: context.env });
103
+ if (current.command !== plan.command || current.kimiVersion !== plan.kimiVersion || JSON.stringify(current.prefixArgs) !== JSON.stringify(plan.prefixArgs))
104
+ throw recoveryError("The exact recorded CLI is not selected. Restore that certified binary before session cleanup.");
105
+ signal.throwIfAborted();
106
+ await deactivateSwarmSession({ cwd: record.cwd, env: context.env, command: current.command,
107
+ prefixArgs: current.prefixArgs, executionPlan: current, commandLabel: expectedRoot ? "swarm-write" : "swarm",
108
+ trustedWorkspaceRoot: expectedRoot ?? undefined, ...record.session, signal,
109
+ onRecoveryEvent: (event) => recordRecoveryEvent(store, record, event) });
110
+ }
111
+ signal.throwIfAborted();
112
+ if (expectedRoot) {
113
+ // Capture without applying or deleting. A crash after the write leaves a recoverable artifact.
114
+ const patch = await captureWorktreePatch(expectedRoot, signal);
115
+ const patchPath = path.join(paths.artifactsDir, `swarm-recovered-${jobId}-${randomUUID()}.patch`);
116
+ await writeFile(patchPath, patch, { flag: "wx", mode: 0o600 });
117
+ record.patchPath = patchPath;
118
+ }
119
+ finishRecoveryRecord(store, record);
120
+ if (!record.complete)
121
+ throw recoveryError("Recovery remains incomplete; evidence and worktree were retained.");
122
+ await settleAbandonedJob();
123
+ return `${JSON.stringify(summary(), null, 2)}\n`;
124
+ }
125
+ finally {
126
+ handlers.dispose();
127
+ try {
128
+ if (claim !== undefined)
129
+ store.claimSwarmRecovery(jobId, claim, null);
130
+ }
131
+ finally {
132
+ store.close();
133
+ }
134
+ }
135
+ }
@@ -22,6 +22,6 @@ export async function runStatus(argv, context) {
22
22
  throw new RuntimeError("JOB_NOT_FOUND", "No matching job was found for status.", "status.lookup");
23
23
  }
24
24
  const job = await reconcileHistoricalJobProvenance(store, found);
25
- return `${JSON.stringify({ ...job, thinking_effort_requested: job.thinking_effort ?? null, hook_escapes: parseHookEscapes(job.hook_escapes), budget_source: renderBudgetSource(job) }, null, 2)}\n`;
25
+ return `${JSON.stringify({ ...job, swarm_recovery: store.getSwarmRecovery(job.job_id)?.record ?? null, thinking_effort_requested: job.thinking_effort ?? null, hook_escapes: parseHookEscapes(job.hook_escapes), budget_source: renderBudgetSource(job) }, null, 2)}\n`;
26
26
  });
27
27
  }
@@ -1,19 +1,22 @@
1
+ import { resolveKimiHome } from "../kimi-home.js";
2
+ import { probeProcessIdentity } from "../process-tree.js";
3
+ import { recordRecoveryEvent, finishRecoveryRecord } from "../swarm-recovery.js";
1
4
  import { allocateJobId } from "../job-id.js";
2
- import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
5
+ import { mkdir, writeFile } from "node:fs/promises";
3
6
  import path from "node:path";
4
7
  import { createCliCancellationHandlers } from "../cli-cancellation.js";
5
8
  import { ResponseTimeoutError, runCliPromptWithBudget } from "../cli-client.js";
6
9
  import { deactivateSwarmSession, preactivateSwarmSession, requiresSwarmPreactivation, } from "../swarm-activation.js";
7
10
  import { executionPlanFromPersisted, observedExecutionFields, persistedExecutionPlanFields, prepareKimiExecutionPlan, } from "../kimi-engine.js";
8
11
  import { RuntimeError } from "../errors.js";
9
- import { captureWorktreePatch, createEphemeralWorktree, hasBornHead, isWorkingTreeDirty, pruneWorktrees, removeWorktree, resolveRepoIdentity, } from "../git.js";
12
+ import { captureWorktreePatch, createEphemeralWorktree, hasBornHead, isWorkingTreeDirty, removeWorktree, resolveRepoIdentity, } from "../git.js";
10
13
  import { digestPrompt, markJobCancelled, markJobFailed, sweepStaleJobs } from "../jobs.js";
11
14
  import { JobStore } from "../job-store.js";
12
15
  import { classifyManagedCommandFailure } from "../kimi-errors.js";
13
16
  import { KIMI_SWARM_DEFAULT_BUDGET_MS } from "../kimi-timeouts.js";
14
17
  import { writeInvocationLogHeader } from "../logging.js";
15
18
  import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
16
- import { MAX_DURATION_MS, parseSwarmArgs } from "../parsing.js";
19
+ import { parseSwarmArgs } from "../parsing.js";
17
20
  import { readArtifact, renderManagedJobOutput, writeArtifact } from "../render.js";
18
21
  import { hookRefusalDetails, hookRefusalRetryProtocol, maybeWarnHookMissing, verifyHookInstalled, } from "../hooks/install.js";
19
22
  import { assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing, warnHookEscapes } from "./cli-helpers.js";
@@ -25,27 +28,11 @@ import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js
25
28
  // only the wall-clock hard bound. Read/write modes reuse review/rescue lineage,
26
29
  // and the mandatory PreToolUse hook enforces read-only policy or trusted-worktree
27
30
  // write confinement on the coordinator and every subagent. Write mode requires
28
- // kimi-code 0.18.0+. Exact 2.3.2 additionally activates a same-job session through
31
+ // kimi-code 0.18.0+. Exact 2.3.2 and 2.3.3 additionally activates a same-job session through
29
32
  // a bounded loopback web lifecycle, then resumes it through the normal print path.
30
33
  const SWARM_SUMMARY_MAX = 120;
31
34
  const SWARM_AGENT_PROFILE = "<swarm>";
32
35
  const SWARM_WRITE_AGENT_PROFILE = "<swarm-write>";
33
- /**
34
- * Orphaned write-swarm worktrees older than this are reaped by the startup sweep.
35
- * Every normal run removes its own worktree in a finally; orphans can follow a
36
- * hard kill or failed cleanup.
37
- *
38
- * The sweep's only liveness signal is the worktree dir's mtime, which is frozen
39
- * at `git worktree add` time when a run edits only EXISTING files (in-place edits
40
- * never bump the containing dir's mtime). A live run can therefore look "idle" for
41
- * its entire wall-clock life. So the TTL MUST exceed the longest a run can live —
42
- * the hard `--budget` ceiling, `MAX_DURATION_MS` (24h) — or a concurrent run's
43
- * sweep could `git worktree remove --force` a still-live worktree out from under
44
- * its owner (ENOENT writes, lost patch). The +2h margin covers budget-expiry
45
- * teardown (AbortController fires → the finally removes the worktree well within
46
- * it). Orphans are still reaped, just after this longer, provably-safe delay.
47
- */
48
- export const WORKTREE_ORPHAN_TTL_MS = MAX_DURATION_MS + 2 * 60 * 60 * 1000;
49
36
  /**
50
37
  * Default concurrency setting when the user passes no `--max-concurrency`.
51
38
  * The plugin always exports KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY; kimi-code
@@ -254,7 +241,7 @@ async function runWriteSwarm(parsed, objective, context) {
254
241
  if (!(await hasBornHead(context.cwd))) {
255
242
  throw new RuntimeError("WRITE_SWARM_NO_HEAD", "/kimi:swarm --write needs a git repository with at least one commit (HEAD). This repo has an unborn HEAD; make an initial commit first.", "swarm.precondition");
256
243
  }
257
- await sweepStaleWorktrees(paths, repoIdentity);
244
+ // Abandoned trees are recovery evidence, never age-based garbage.
258
245
  if (await isWorkingTreeDirty(context.cwd)) {
259
246
  context.stderr.write("[kimi-plugin-cc] /kimi:swarm --write bases its worktree on HEAD — your uncommitted changes are NOT visible to the swarm. Commit or stash them first if the swarm needs them.\n");
260
247
  }
@@ -362,37 +349,6 @@ function assertWriteSwarmPreconditions(repoIdentity) {
362
349
  throw new RuntimeError("WRITE_SWARM_NOT_A_REPO", "/kimi:swarm --write requires a git repository (it works in a throwaway worktree off HEAD). The current directory is not inside a git repo.", "swarm.precondition");
363
350
  }
364
351
  }
365
- /**
366
- * Reap orphaned write-swarm worktrees from prior crashed runs. Normal runs remove
367
- * their own worktree in a finally; orphans can follow hard kills or failed cleanup.
368
- * Prune stale admin entries (current repo), then force-remove on-disk worktree
369
- * dirs older than the TTL. Best-effort — never blocks the run.
370
- */
371
- async function sweepStaleWorktrees(paths, repoIdentity) {
372
- await pruneWorktrees(repoIdentity.repoRoot);
373
- // Only sweep THIS repo's namespaced subdir, so we never `rm` a worktree that a
374
- // different repo still considers live (and never leave that repo a dangling
375
- // admin entry). repoId is the realpath-stable hash from resolveRepoIdentity.
376
- const repoWorktreesDir = path.join(paths.worktreesDir, repoIdentity.repoId);
377
- const entries = await readdir(repoWorktreesDir, { withFileTypes: true }).catch(() => null);
378
- if (entries === null)
379
- return; // not created yet — nothing to sweep.
380
- const now = Date.now();
381
- for (const entry of entries) {
382
- if (!entry.isDirectory() || !entry.name.startsWith("swarm-write-"))
383
- continue;
384
- const full = path.join(repoWorktreesDir, entry.name);
385
- try {
386
- const st = await stat(full);
387
- if (now - st.mtimeMs > WORKTREE_ORPHAN_TTL_MS) {
388
- await removeWorktree(repoIdentity.repoRoot, full);
389
- }
390
- }
391
- catch {
392
- // best-effort per entry
393
- }
394
- }
395
- }
396
352
  /**
397
353
  * Capture the worktree change set as a `.patch` artifact (write mode). Returns
398
354
  * the artifact path + size, or null on capture failure. Called on EVERY terminal
@@ -474,6 +430,7 @@ writeMode) {
474
430
  let cleanupAttempted = false;
475
431
  let cleanupFailure;
476
432
  let finalText;
433
+ let recovery;
477
434
  const commandLabel = writeMode ? "swarm-write" : "swarm";
478
435
  const recoveryCleanupTimeoutMs = 2_000;
479
436
  const cleanupSignal = (timeoutMs) => AbortSignal.any([
@@ -499,6 +456,14 @@ writeMode) {
499
456
  };
500
457
  try {
501
458
  const executionPlan = executionPlanFromPersisted(job);
459
+ if (requiresSwarmPreactivation(executionPlan.kimiVersion)) {
460
+ const owner = await probeProcessIdentity(process.pid);
461
+ recovery = { schema: 1, jobId: job.job_id, ownerPid: process.pid, owner: owner.identity ?? null,
462
+ kimiHome: resolveKimiHome(context.env, writeMode?.spawnCwd ?? job.cwd),
463
+ cwd: writeMode?.spawnCwd ?? job.cwd, workspaceRoot: writeMode?.workspaceRoot ?? null,
464
+ launches: [], session: null, sessionDirty: false, complete: false, patchPath: null };
465
+ store.saveSwarmRecovery(job.job_id, recovery);
466
+ }
502
467
  activationOptions = {
503
468
  cwd: writeMode?.spawnCwd ?? job.cwd,
504
469
  env: context.env,
@@ -511,6 +476,7 @@ writeMode) {
511
476
  swarmMaxConcurrency: maxConcurrency,
512
477
  trustedWorkspaceRoot: writeMode?.workspaceRoot,
513
478
  expectedVersion: executionPlan.kimiVersion ?? undefined,
479
+ onRecoveryEvent: recovery ? (event) => recordRecoveryEvent(store, recovery, event) : undefined,
514
480
  };
515
481
  if (requiresSwarmPreactivation(executionPlan.kimiVersion)) {
516
482
  store.updateRunningJob(job.job_id, { phase: "turn-running" });
@@ -577,6 +543,8 @@ writeMode) {
577
543
  if (promptBudgetMs <= 0)
578
544
  throw new ResponseTimeoutError(budgetMs, "swarm.prompt", commandLabel);
579
545
  store.updateRunningJob(job.job_id, { phase: "turn-running" });
546
+ if (recovery)
547
+ recordRecoveryEvent(store, recovery, { kind: "print-spawn" });
580
548
  const result = await runCliPromptWithBudget({
581
549
  cwd: activationOptions.cwd,
582
550
  env: context.env,
@@ -591,9 +559,17 @@ writeMode) {
591
559
  thinkingEffort: job.thinking_effort ?? undefined,
592
560
  resumeSessionId: preactivated?.sessionId,
593
561
  logPath: job.stream_log_path,
594
- onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
562
+ onSpawn: async (pid) => {
563
+ store.updateRunningJob(job.job_id, { kimi_pid: pid });
564
+ if (recovery) {
565
+ const probe = await probeProcessIdentity(pid);
566
+ recordRecoveryEvent(store, recovery, { kind: "print-processes", identities: probe.identity ? [probe.identity] : [], complete: probe.complete });
567
+ }
568
+ },
595
569
  signal: handlers.signal,
596
570
  }, promptBudgetMs, "swarm.prompt");
571
+ if (recovery)
572
+ recordRecoveryEvent(store, recovery, { kind: "print-exit" });
597
573
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
598
574
  finalText = reassembleProseFromRecords(result.records);
599
575
  let resultError;
@@ -644,6 +620,8 @@ writeMode) {
644
620
  if (captured === null) {
645
621
  throw new RuntimeError("SWARM_PATCH_CAPTURE_FAILED", `Swarm patch capture failed; worktree retained at ${writeMode.workspaceRoot}.`, "swarm.patch-capture", { details: { worktreePath: writeMode.workspaceRoot } });
646
622
  }
623
+ if (recovery)
624
+ recovery.patchPath = captured.path;
647
625
  finalText = `${renderWriteSwarmHeader(captured)}\n\n---\n\n${prose}`.trimEnd();
648
626
  }
649
627
  const rendered = renderManagedJobOutput(job, finalText);
@@ -662,6 +640,8 @@ writeMode) {
662
640
  if (handlers.cancelling) {
663
641
  throw new RuntimeError("SWARM_CANCELLED", "Swarm cancelled after artifact write.", "swarm.runtime");
664
642
  }
643
+ if (recovery)
644
+ finishRecoveryRecord(store, recovery);
665
645
  return (store.markCompleted(job.job_id, {
666
646
  summary: `[${writeMode ? "swarm-write" : "swarm"}] ${shorten(objective, SWARM_SUMMARY_MAX)}`,
667
647
  phase: "done",
@@ -689,6 +669,8 @@ writeMode) {
689
669
  if (writeMode && !(error instanceof RuntimeError && error.code === "SWARM_PATCH_CAPTURE_FAILED")) {
690
670
  const captured = await capturePatchArtifact(paths, job.job_id, writeMode.workspaceRoot, context.stderr, writeMode.onPatchCaptureFailure);
691
671
  captureFailed = captured === null;
672
+ if (recovery && captured)
673
+ recovery.patchPath = captured.path;
692
674
  if (captured && !captured.empty) {
693
675
  context.stderr.write(`[kimi-plugin-cc] swarm-write run ended early; partial patch captured at ${captured.path}.\n`);
694
676
  }
@@ -745,7 +727,22 @@ writeMode) {
745
727
  }
746
728
  finally {
747
729
  handlers.dispose();
748
- store.close();
730
+ try {
731
+ if (recovery)
732
+ finishRecoveryRecord(store, recovery);
733
+ if (recovery && !recovery.complete) {
734
+ writeMode?.onPatchCaptureFailure?.();
735
+ context.stderr.write(`[kimi-plugin-cc] recovery record retained: companion.sh recover-swarm ${job.job_id} (preview).\n`);
736
+ }
737
+ }
738
+ catch (error) {
739
+ // A failed final journal commit cannot authorize removal of recovery evidence.
740
+ writeMode?.onPatchCaptureFailure?.();
741
+ throw error;
742
+ }
743
+ finally {
744
+ store.close();
745
+ }
749
746
  }
750
747
  }
751
748
  function shorten(text, max) {
package/dist/companion.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { runRecoverSwarm } from "./commands/recover-swarm.js";
1
2
  import { companionHelp } from "./help.js";
2
3
  import { runModelInventory } from "./commands/models.js";
3
4
  import { runCancel } from "./commands/cancel.js";
@@ -77,6 +78,9 @@ async function main(argv) {
77
78
  case "replay":
78
79
  context.stdout.write(await runReplay(rest, context));
79
80
  return;
81
+ case "recover-swarm":
82
+ context.stdout.write(await runRecoverSwarm(rest, context));
83
+ return;
80
84
  case "repair-sessions":
81
85
  context.stdout.write(await runRepairSessions(rest, context));
82
86
  return;
@@ -84,7 +88,7 @@ async function main(argv) {
84
88
  await runWorker(rest, context);
85
89
  return;
86
90
  default:
87
- throw new RuntimeError("INVALID_COMMAND", `Unknown or missing companion subcommand: ${command ?? "<none>"}. Expected one of setup, review, task, ask, status, result, cancel, replay, repair-sessions.`, "companion");
91
+ throw new RuntimeError("INVALID_COMMAND", `Unknown or missing companion subcommand: ${command ?? "<none>"}. Expected one of setup, review, task, ask, status, result, cancel, replay, repair-sessions, recover-swarm.`, "companion");
88
92
  }
89
93
  }
90
94
  main(process.argv.slice(2)).catch((error) => {