kimi-plugin-cc 2.4.0 → 2.4.1

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.
@@ -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.0"
8
+ "version": "2.4.1"
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.0",
4
+ "version": "2.4.1",
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,15 @@
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.1 — 2026-10-25
6
+
7
+ - 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).
8
+ - Preserve swarm support after upstream removed model self-activation: exact-version, authenticated loopback activation creates a same-job session with the selected model, auto permission, and plan mode off, then resumes through ordinary `kimi -p`. `runSwarm` solely owns bounded deactivation and verifies the journal; older certified versions stay on direct print.
9
+ - Bind activation to exact provenance, finite budgets, process identity, and session identity (`preactivated`). Failure recovery is cancellation-aware and preserves primary print errors. Unknown late-fork or changed-PGID survivors remain unverified, never false quiescence or unproven signal targets. Cleanup failure blocks completion and retains raw output, structured diagnostics, and the write worktree. MCP uses the same implementation and existing task/job ownership.
10
+ - Close a certification blind spot found by adversarial review: AgentSwarm call records, aggregate denial text, and coordinator-only patches no longer count as fan-out. Smoke requires distinct child journals and correlates each assigned Write/Edit path and toolCallId with its own denial or successful result; the final production run passed 19/19 with 125 assertions in 296.67 seconds.
11
+
12
+ Contributors: **Kimi** (source audit, design, implementation, documentation, and verification), **Claude** and **Codex** (adversarial review and re-review).
13
+
5
14
  ## 2.4.0 — 2026-10-24
6
15
 
7
16
  - Add one dependency-free MCP stdio server for Claude Code and Codex, with typed schemas for seven model operations plus job status, result, and cancellation. The existing slash commands, skills, safety hooks, and review gate remain intact.
package/README.md CHANGED
@@ -40,7 +40,7 @@ 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.1 (2.3.0 also certified), the recommended version for plugin 2.4.0
43
+ - Kimi Code CLI 2.3.2 (certified; 2.3.1 and 2.3.0 are also certified)
44
44
 
45
45
  Windows is not currently supported. Check your versions in a terminal:
46
46
 
@@ -100,12 +100,12 @@ If you use both Claude Code and Codex, run setup in each. Each app has its own h
100
100
  ## npm distribution (optional)
101
101
 
102
102
  The existing marketplace commands above remain the default for Claude Code and
103
- Codex. The same release is also available as
103
+ Codex. Published releases are also available as
104
104
  [`kimi-plugin-cc` on npm](https://www.npmjs.com/package/kimi-plugin-cc), including
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:
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:
109
109
 
110
110
  ```sh
111
111
  npm install --prefix ~/.local/share/kimi-plugin-cc kimi-plugin-cc@2.4.0
@@ -228,6 +228,8 @@ 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.
232
+
231
233
  The plugin does not commit changes. Review changes before you keep or apply them.
232
234
 
233
235
  Only use tasks that can write files in repositories you trust. Rescue, pursue, and swarm with `--write` can run tests and similar commands. These commands execute code from your repository. The hook restricts tool use; it is not an operating-system sandbox.
@@ -262,7 +264,7 @@ Contributors need Bun to run `bun run check`. The detailed engineering documents
262
264
  - macOS 或 Linux
263
265
  - 支持插件的 Claude Code 或 Codex
264
266
  - Node.js 22.5 或更高版本
265
- - Kimi Code CLI 2.3.1(2.3.0 亦已认证),这是插件 2.4.0 的推荐版本
267
+ - Kimi Code CLI 2.3.2(已认证;2.3.1 和 2.3.0 也已认证)
266
268
 
267
269
  目前不支持 Windows。在终端检查版本:
268
270
 
@@ -425,6 +427,8 @@ codex plugin add kimi@kimi-marketplace
425
427
 
426
428
  Ask、review、challenge 和只读 swarm 的工具规则禁止写入文件和执行 shell 命令。Rescue 和 pursue 可以修改工作区内的文件。带 `--write` 的 swarm 在临时 Git 工作树中操作。它从最新提交开始,不包含尚未提交的修改。任务结束后返回补丁,不会自动应用到项目。
427
429
 
430
+ 在 CLI 2.3.2 上运行 swarm 时,插件会短暂启动需要认证的本地回环 `kimi web` 进程来激活会话,并在结束后再次启动以停用 swarm 模式;任务本身仍由一个普通的 `kimi -p -r` 进程执行。激活计入任务时限,访问令牌只保存在内存且不会写入日志;无法确认清理完成时,任务不会报告成功。较早的已认证版本仍直接通过 `kimi -p` 运行。
431
+
428
432
  插件不会提交修改。保留或应用修改前,请先审查。
429
433
 
430
434
  只在你信任的仓库中运行能修改文件的任务。Rescue、pursue 和带 `--write` 的 swarm 可以运行测试等命令。这些命令会执行仓库中的代码。钩子限制的是工具调用,它不是操作系统沙箱。
@@ -459,7 +463,7 @@ Il vous faut :
459
463
  - macOS ou Linux
460
464
  - une version de Claude Code ou de Codex prenant en charge les plugins
461
465
  - Node.js 22.5 ou une version ultérieure
462
- - Kimi Code CLI 2.3.1 (2.3.0 également certifiée), la version recommandée pour le plugin 2.4.0
466
+ - Kimi Code CLI 2.3.2 (certifiée ; les versions 2.3.1 et 2.3.0 sont également certifiées)
463
467
 
464
468
  Windows n'est pas pris en charge actuellement. Vérifiez les versions dans un terminal :
465
469
 
@@ -622,6 +626,8 @@ Tous les codes de refus et leurs remèdes sont répertoriés dans le [catalogue
622
626
 
623
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.
624
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`.
630
+
625
631
  Le plugin ne crée aucun commit. Examinez les modifications avant de les conserver ou de les appliquer.
626
632
 
627
633
  N'utilisez les tâches pouvant écrire des fichiers que dans des dépôts auxquels vous faites confiance. Rescue, pursue et swarm avec `--write` peuvent lancer des tests et des commandes similaires. Ces commandes exécutent du code du dépôt. Le hook limite l'utilisation des outils ; ce n'est pas un bac à sable du système d'exploitation.
@@ -656,7 +662,7 @@ Les contributeurs ont besoin de Bun pour exécuter `bun run check`. La documenta
656
662
  - macOS または Linux
657
663
  - プラグインに対応した Claude Code または Codex
658
664
  - Node.js 22.5 以降
659
- - Kimi Code CLI 2.3.1(2.3.0 も認証済み。プラグイン 2.4.0 の推奨バージョン)
665
+ - Kimi Code CLI 2.3.2(認証済み。2.3.1 と 2.3.0 も認証済み)
660
666
 
661
667
  現在、Windows には対応していません。ターミナルでバージョンを確認します。
662
668
 
@@ -819,6 +825,8 @@ codex plugin add kimi@kimi-marketplace
819
825
 
820
826
  Ask、review、challenge、読み取り専用 swarm のツールルールでは、ファイルへの書き込みとシェルコマンドを禁止しています。Rescue と pursue はワークスペース内のファイルを変更できます。`--write` を指定した swarm は、一時的な Git ワークツリーで作業します。最新のコミットから開始するため、未コミットの変更は含まれません。パッチを返すだけで、プロジェクトには自動適用しません。
821
827
 
828
+ CLI 2.3.2 の swarm では、セッションを有効化するため、そして終了後に swarm モードを無効化するため、認証付きのローカルループバック `kimi web` プロセスをそれぞれ短時間だけ起動します。タスク自体は従来どおり 1 つの `kimi -p -r` プロセスが実行します。有効化も制限時間に含まれ、トークンはメモリー内にのみ保持されてログには出ません。後処理を確認できない場合、タスクは成功と報告されません。以前の認証済みバージョンでは、引き続き `kimi -p` を直接使います。
829
+
822
830
  プラグインは変更をコミットしません。変更を残す、または適用する前に内容を確認してください。
823
831
 
824
832
  ファイルを書き換えるタスクは、信頼できるリポジトリでのみ実行してください。Rescue、pursue、`--write` 付きの swarm は、テストなどのコマンドを実行できます。これらのコマンドはリポジトリ内のコードを実行します。フックはツールの使用を制限しますが、OS のサンドボックスではありません。
@@ -60,7 +60,7 @@ export async function runCliPrompt(opts) {
60
60
  });
61
61
  }
62
62
  const args = buildArgs(opts);
63
- const env = buildEnv(opts);
63
+ const env = buildKimiCliEnvironment(opts);
64
64
  if (opts.logPath !== undefined) {
65
65
  await mkdir(path.dirname(opts.logPath), { recursive: true }).catch(() => {
66
66
  // Best-effort; if mkdir fails the appendFile below will error and be
@@ -788,7 +788,7 @@ function buildArgs(opts) {
788
788
  args.push("-p", opts.prompt);
789
789
  return args;
790
790
  }
791
- function buildEnv(opts) {
791
+ export function buildKimiCliEnvironment(opts) {
792
792
  const env = { ...opts.env };
793
793
  if (opts.thinkingEffort !== undefined)
794
794
  env.KIMI_MODEL_THINKING_EFFORT = opts.thinkingEffort;
@@ -117,7 +117,11 @@ export async function captureTimeoutPartialOutput(classified, store, job, env, c
117
117
  return undefined;
118
118
  const result = timeout.partialResult;
119
119
  let sessionId = result.sessionId;
120
- let source = sessionId ? "announced" : null;
120
+ let source = sessionId
121
+ ? sessionId === job.kimi_session_id
122
+ ? job.kimi_session_id_source
123
+ : "announced"
124
+ : null;
121
125
  const details = classified instanceof RuntimeError ? classified.details : timeout.details;
122
126
  details.kimi_session_id_source = job.kimi_session_id_source;
123
127
  // The persisted plan is plugin-owned. Recovery never runs on cancellation,
@@ -2,7 +2,8 @@ import { allocateJobId } from "../job-id.js";
2
2
  import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { createCliCancellationHandlers } from "../cli-cancellation.js";
5
- import { runCliPromptWithBudget } from "../cli-client.js";
5
+ import { ResponseTimeoutError, runCliPromptWithBudget } from "../cli-client.js";
6
+ import { deactivateSwarmSession, preactivateSwarmSession, requiresSwarmPreactivation, } from "../swarm-activation.js";
6
7
  import { executionPlanFromPersisted, observedExecutionFields, persistedExecutionPlanFields, prepareKimiExecutionPlan, } from "../kimi-engine.js";
7
8
  import { RuntimeError } from "../errors.js";
8
9
  import { captureWorktreePatch, createEphemeralWorktree, hasBornHead, isWorkingTreeDirty, pruneWorktrees, removeWorktree, resolveRepoIdentity, } from "../git.js";
@@ -24,7 +25,8 @@ import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js
24
25
  // only the wall-clock hard bound. Read/write modes reuse review/rescue lineage,
25
26
  // and the mandatory PreToolUse hook enforces read-only policy or trusted-worktree
26
27
  // write confinement on the coordinator and every subagent. Write mode requires
27
- // kimi-code 0.18.0+.
28
+ // kimi-code 0.18.0+. Exact 2.3.2 additionally activates a same-job session through
29
+ // a bounded loopback web lifecycle, then resumes it through the normal print path.
28
30
  const SWARM_SUMMARY_MAX = 120;
29
31
  const SWARM_AGENT_PROFILE = "<swarm>";
30
32
  const SWARM_WRITE_AGENT_PROFILE = "<swarm-write>";
@@ -87,7 +89,7 @@ export function resolveSwarmWriteMaxConcurrency(requested) {
87
89
  * subagent, so slimming this text does not move the safety boundary. The optional
88
90
  * `cap` is a SOFT subagent-count hint (the hook cannot enforce a count). The HARD
89
91
  * concurrency ceiling is separate: --max-concurrency →
90
- * KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY (see executeSwarmJob / cli-client buildEnv).
92
+ * KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY (see executeSwarmJob / cli-client buildKimiCliEnvironment).
91
93
  * The --budget wall-clock ceiling is the always-on hard bound on the whole run.
92
94
  */
93
95
  export function buildSwarmPrompt(objective, cap) {
@@ -343,7 +345,7 @@ async function runWriteSwarm(parsed, objective, context) {
343
345
  }
344
346
  finally {
345
347
  if (preserveWorktree) {
346
- context.stderr.write(`[kimi-plugin-cc] swarm-write patch capture failed; worktree retained at ${worktreePath}.\n`);
348
+ context.stderr.write(`[kimi-plugin-cc] swarm-write patch capture or session cleanup failed; worktree retained at ${worktreePath}.\n`);
347
349
  }
348
350
  else {
349
351
  await removeWorktree(repoIdentity.repoRoot, worktreePath);
@@ -465,44 +467,163 @@ writeMode) {
465
467
  }
466
468
  }
467
469
  const handlers = createCliCancellationHandlers();
470
+ const budgetDeadline = Date.now() + budgetMs;
471
+ let activationOptions;
472
+ let preactivated;
473
+ let deactivationRequired = false;
474
+ let cleanupAttempted = false;
475
+ let cleanupFailure;
476
+ let finalText;
477
+ const commandLabel = writeMode ? "swarm-write" : "swarm";
478
+ const recoveryCleanupTimeoutMs = 2_000;
479
+ const cleanupSignal = (timeoutMs) => AbortSignal.any([
480
+ handlers.signal,
481
+ AbortSignal.timeout(Math.max(1, timeoutMs)),
482
+ ]);
483
+ const attemptDeactivation = async (identity, fallbackStage, timeoutMs = Math.max(1, budgetDeadline - Date.now())) => {
484
+ cleanupAttempted = true;
485
+ try {
486
+ await deactivateSwarmSession({
487
+ ...activationOptions,
488
+ ...identity,
489
+ signal: cleanupSignal(timeoutMs),
490
+ });
491
+ deactivationRequired = false;
492
+ return undefined;
493
+ }
494
+ catch (error) {
495
+ return error instanceof RuntimeError
496
+ ? error
497
+ : new RuntimeError("SWARM_SESSION_CLEANUP_FAILED", "Swarm session cleanup failed without a safe diagnostic payload.", fallbackStage);
498
+ }
499
+ };
468
500
  try {
469
501
  const executionPlan = executionPlanFromPersisted(job);
502
+ activationOptions = {
503
+ cwd: writeMode?.spawnCwd ?? job.cwd,
504
+ env: context.env,
505
+ command: executionPlan.command,
506
+ prefixArgs: executionPlan.prefixArgs,
507
+ executionPlan,
508
+ commandLabel,
509
+ model: job.model ?? undefined,
510
+ thinkingEffort: job.thinking_effort ?? undefined,
511
+ swarmMaxConcurrency: maxConcurrency,
512
+ trustedWorkspaceRoot: writeMode?.workspaceRoot,
513
+ expectedVersion: executionPlan.kimiVersion ?? undefined,
514
+ };
515
+ if (requiresSwarmPreactivation(executionPlan.kimiVersion)) {
516
+ store.updateRunningJob(job.job_id, { phase: "turn-running" });
517
+ const activationController = new AbortController();
518
+ const onParentAbort = () => activationController.abort();
519
+ let activationTimedOut = false;
520
+ handlers.signal.addEventListener("abort", onParentAbort, { once: true });
521
+ if (handlers.signal.aborted)
522
+ onParentAbort();
523
+ const activationTimer = setTimeout(() => {
524
+ activationTimedOut = true;
525
+ activationController.abort();
526
+ }, Math.max(1, budgetDeadline - Date.now()));
527
+ activationTimer.unref();
528
+ try {
529
+ preactivated = await preactivateSwarmSession({
530
+ ...activationOptions,
531
+ signal: activationController.signal,
532
+ });
533
+ deactivationRequired = true;
534
+ store.updateRunningJob(job.job_id, {
535
+ kimi_session_id: preactivated.sessionId,
536
+ kimi_session_id_source: "preactivated",
537
+ });
538
+ }
539
+ catch (error) {
540
+ const activationDidTimeOut = activationTimedOut;
541
+ clearTimeout(activationTimer);
542
+ handlers.signal.removeEventListener("abort", onParentAbort);
543
+ const details = error instanceof RuntimeError ? error.details : {};
544
+ const sessionId = typeof details.session_id === "string" ? details.session_id : undefined;
545
+ const workspaceId = typeof details.workspace_id === "string" ? details.workspace_id : undefined;
546
+ const hasSessionIdentity = sessionId !== undefined && workspaceId !== undefined;
547
+ if (error instanceof RuntimeError &&
548
+ error.code === "SWARM_SESSION_CLEANUP_FAILED" &&
549
+ details.process_cleanup_unverified === true) {
550
+ cleanupFailure = error;
551
+ }
552
+ if (hasSessionIdentity) {
553
+ store.updateRunningJob(job.job_id, {
554
+ kimi_session_id: sessionId,
555
+ kimi_session_id_source: "preactivated",
556
+ });
557
+ }
558
+ if (hasSessionIdentity && !handlers.cancelling) {
559
+ const sessionCleanupFailure = await attemptDeactivation({ sessionId, workspaceId }, "swarm.activation-cleanup", recoveryCleanupTimeoutMs);
560
+ if (sessionCleanupFailure)
561
+ cleanupFailure = sessionCleanupFailure;
562
+ }
563
+ else if (hasSessionIdentity && cleanupFailure === undefined) {
564
+ cleanupFailure = new RuntimeError("SWARM_SESSION_CLEANUP_FAILED", "Swarm session cleanup was not completed after activation was cancelled.", "swarm.activation-cleanup", { details: { cancelled: true, cleanup_unverified: true } });
565
+ }
566
+ if (activationDidTimeOut && !handlers.cancelling) {
567
+ throw new ResponseTimeoutError(budgetMs, "swarm.activation", commandLabel, undefined, error);
568
+ }
569
+ throw error;
570
+ }
571
+ finally {
572
+ clearTimeout(activationTimer);
573
+ handlers.signal.removeEventListener("abort", onParentAbort);
574
+ }
575
+ }
576
+ const promptBudgetMs = budgetDeadline - Date.now();
577
+ if (promptBudgetMs <= 0)
578
+ throw new ResponseTimeoutError(budgetMs, "swarm.prompt", commandLabel);
470
579
  store.updateRunningJob(job.job_id, { phase: "turn-running" });
471
580
  const result = await runCliPromptWithBudget({
472
- // Read mode spawns in the user's cwd; WRITE mode spawns in the throwaway
473
- // worktree so coder subagents edit there (job.cwd stays the user's real
474
- // cwd for lineage). This is the load-bearing isolation seam.
475
- cwd: writeMode?.spawnCwd ?? job.cwd,
581
+ cwd: activationOptions.cwd,
476
582
  env: context.env,
477
583
  command: executionPlan.command,
478
584
  prefixArgs: [...executionPlan.prefixArgs],
479
585
  executionPlan,
480
586
  prompt,
481
- // "swarm" → read-only-plus-AgentSwarm allowlist; "swarm-write" → the same
482
- // PLUS rescue-grade write confinement to the trusted worktree root. The
483
- // label drives the hook for the coordinator AND every spawned subagent.
484
- commandLabel: writeMode ? "swarm-write" : "swarm",
485
- // Always exported as KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY. kimi-code
486
- // 0.18.0+ enforces this hard peak ceiling; older read-only binaries ignore
487
- // it and retain only the wall-clock hard bound. Write mode requires
488
- // 0.18.0+. Distinct from --cap, the soft total-count prompt hint.
587
+ commandLabel,
489
588
  swarmMaxConcurrency: maxConcurrency,
490
- // Trusted allowlist root for the swarm-write hook (forge-proof; ignored
491
- // by other labels). Only set in write mode.
492
589
  trustedWorkspaceRoot: writeMode?.workspaceRoot,
493
590
  model: job.model ?? undefined,
494
591
  thinkingEffort: job.thinking_effort ?? undefined,
592
+ resumeSessionId: preactivated?.sessionId,
495
593
  logPath: job.stream_log_path,
496
594
  onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
497
595
  signal: handlers.signal,
498
- }, budgetMs, "swarm.prompt");
596
+ }, promptBudgetMs, "swarm.prompt");
499
597
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
598
+ finalText = reassembleProseFromRecords(result.records);
599
+ let resultError;
500
600
  if (handlers.cancelling) {
501
- throw new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
601
+ resultError = new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
602
+ }
603
+ else {
604
+ try {
605
+ assertCliResultSuccess(result, "swarm.runtime");
606
+ if (preactivated && result.sessionId !== preactivated.sessionId) {
607
+ throw new RuntimeError("SWARM_SESSION_MISMATCH", "The resumed print session differs from the preactivated swarm session.", "swarm.session-provenance", { details: { expected_session_id: preactivated.sessionId, actual_session_id: result.sessionId ?? null } });
608
+ }
609
+ }
610
+ catch (error) {
611
+ resultError = error;
612
+ }
502
613
  }
503
- assertCliResultSuccess(result, "swarm.runtime");
504
614
  warnHookEscapes(result, "swarm", context.stderr);
505
- if (result.sessionId !== undefined &&
615
+ if (preactivated) {
616
+ cleanupFailure = await attemptDeactivation(preactivated, "swarm.cleanup");
617
+ }
618
+ if (resultError !== undefined)
619
+ throw resultError;
620
+ if (handlers.cancelling) {
621
+ throw new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
622
+ }
623
+ if (cleanupFailure)
624
+ throw cleanupFailure;
625
+ if (!preactivated &&
626
+ result.sessionId !== undefined &&
506
627
  result.sessionId.length > 0 &&
507
628
  result.sessionId !== job.kimi_session_id) {
508
629
  store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
@@ -517,7 +638,7 @@ writeMode) {
517
638
  });
518
639
  warnIfSessionIdMissing(result, "swarm", job.job_id, context.stderr);
519
640
  const prose = reassembleProseFromRecords(result.records);
520
- let finalText = prose;
641
+ finalText = prose;
521
642
  if (writeMode) {
522
643
  const captured = await capturePatchArtifact(paths, job.job_id, writeMode.workspaceRoot, context.stderr, writeMode.onPatchCaptureFailure);
523
644
  if (captured === null) {
@@ -549,6 +670,21 @@ writeMode) {
549
670
  }) ?? job);
550
671
  }
551
672
  catch (error) {
673
+ if (preactivated && deactivationRequired && activationOptions) {
674
+ if (handlers.cancelling) {
675
+ cleanupFailure = new RuntimeError("SWARM_SESSION_CLEANUP_FAILED", "Swarm session cleanup was not completed after cancellation.", "swarm.cleanup", { details: { cancelled: true, cleanup_unverified: true } });
676
+ }
677
+ else if (!cleanupAttempted) {
678
+ cleanupFailure = await attemptDeactivation(preactivated, "swarm.cleanup", recoveryCleanupTimeoutMs);
679
+ }
680
+ else if (error instanceof RuntimeError && error.code === "SWARM_SESSION_CLEANUP_FAILED") {
681
+ cleanupFailure ??= error;
682
+ }
683
+ }
684
+ if (cleanupFailure) {
685
+ writeMode?.onPatchCaptureFailure?.();
686
+ context.stderr.write(`[kimi-plugin-cc] swarm session cleanup also failed: ${cleanupFailure.message}\n`);
687
+ }
552
688
  let captureFailed = writeMode !== undefined && error instanceof RuntimeError && error.code === "SWARM_PATCH_CAPTURE_FAILED";
553
689
  if (writeMode && !(error instanceof RuntimeError && error.code === "SWARM_PATCH_CAPTURE_FAILED")) {
554
690
  const captured = await capturePatchArtifact(paths, job.job_id, writeMode.workspaceRoot, context.stderr, writeMode.onPatchCaptureFailure);
@@ -557,34 +693,55 @@ writeMode) {
557
693
  context.stderr.write(`[kimi-plugin-cc] swarm-write run ended early; partial patch captured at ${captured.path}.\n`);
558
694
  }
559
695
  }
560
- const recoveryDetails = captureFailed && writeMode ? { worktreePath: writeMode.workspaceRoot } : {};
696
+ const recoveryDetails = {
697
+ ...(error instanceof RuntimeError && typeof error.details.exit_code === "number"
698
+ ? { exit_code: error.details.exit_code }
699
+ : {}),
700
+ ...((captureFailed || cleanupFailure) && writeMode ? { worktreePath: writeMode.workspaceRoot } : {}),
701
+ ...(cleanupFailure
702
+ ? {
703
+ cleanup_error: {
704
+ code: cleanupFailure.code,
705
+ stage: cleanupFailure.stage,
706
+ cancelled: handlers.cancelling,
707
+ unverified: true,
708
+ },
709
+ ...(finalText !== undefined ? { rawOutput: finalText } : {}),
710
+ }
711
+ : {}),
712
+ };
561
713
  if (handlers.cancelling) {
714
+ const cancellationCause = classifyManagedCommandFailure(error, writeMode ? "rescue" : "review", job.job_id, {
715
+ preserveStage: true,
716
+ });
562
717
  const cancelledError = new RuntimeError("SWARM_CANCELLED", captureFailed && writeMode
563
718
  ? `Swarm cancelled; patch capture failed and worktree retained at ${writeMode.workspaceRoot}.`
564
719
  : "Swarm cancelled by user request.", "swarm.runtime", {
565
720
  ...(error instanceof Error ? { cause: error } : {}),
566
- details: recoveryDetails,
721
+ details: {
722
+ ...(cancellationCause instanceof RuntimeError ? cancellationCause.details : {}),
723
+ ...recoveryDetails,
724
+ },
567
725
  });
568
726
  return await markJobCancelled(store, paths, job, "Swarm cancelled by user request.", cancelledError, {
569
727
  phase: "cancelled",
570
728
  });
571
729
  }
730
+ if (error === cleanupFailure && budgetDeadline <= Date.now() && !(error instanceof ResponseTimeoutError)) {
731
+ error = new ResponseTimeoutError(budgetMs, "swarm.cleanup", commandLabel, undefined, cleanupFailure);
732
+ }
572
733
  const classified = classifyManagedCommandFailure(error, writeMode ? "rescue" : "review", job.job_id, {
573
734
  preserveStage: true,
574
735
  });
575
- if (captureFailed && writeMode) {
576
- const withRecovery = classified instanceof RuntimeError
736
+ const withRecovery = captureFailed || cleanupFailure
737
+ ? classified instanceof RuntimeError
577
738
  ? new RuntimeError(classified.code, classified.message, classified.stage, {
578
739
  cause: classified,
579
740
  details: { ...classified.details, ...recoveryDetails },
580
741
  })
581
- : new RuntimeError("SWARM_PATCH_CAPTURE_FAILED", classified.message, "swarm.patch-capture", {
582
- cause: classified,
583
- details: recoveryDetails,
584
- });
585
- return await markJobFailed(store, paths, job, withRecovery, "Swarm failed.", { phase: "failed" });
586
- }
587
- return await markJobFailed(store, paths, job, classified, "Swarm failed.", { phase: "failed" });
742
+ : new RuntimeError(cleanupFailure?.code ?? "SWARM_PATCH_CAPTURE_FAILED", classified.message, cleanupFailure?.stage ?? "swarm.patch-capture", { cause: classified, details: recoveryDetails })
743
+ : classified;
744
+ return await markJobFailed(store, paths, job, withRecovery, "Swarm failed.", { phase: "failed" });
588
745
  }
589
746
  finally {
590
747
  handlers.dispose();
@@ -594,8 +594,8 @@ 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) and 2.3.1 (684bae9e): the additive events and HookDefSchema
598
- // are unchanged. Require the full version string AND matching components;
597
+ // 2.3.0 (b5e28fd6), 2.3.1 (684bae9e) and 2.3.2 (b8dd3a13): the additive
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) ||
601
601
  (version.version === "2.0.1" && version.patch === 1) ||
@@ -604,7 +604,8 @@ function validateVersionSensitiveEvents(events, version) {
604
604
  (version.version === "2.1.1" && version.patch === 1))) ||
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
- (version.version === "2.3.1" && version.patch === 1))))) {
607
+ (version.version === "2.3.1" && version.patch === 1) ||
608
+ (version.version === "2.3.2" && version.patch === 2))))) {
608
609
  return { valid: true };
609
610
  }
610
611
  if (version.major !== 0) {
@@ -41,6 +41,7 @@ export const NATIVE_V2_CERTIFIED_VERSIONS = Object.freeze([
41
41
  "2.2.0",
42
42
  "2.3.0",
43
43
  "2.3.1",
44
+ "2.3.2",
44
45
  ]);
45
46
  /** Per-operation native-v2 certification. One green operation never certifies another. */
46
47
  export const NATIVE_V2_CERTIFIED = new Map([
@@ -6,7 +6,7 @@ const MAX_DESCENDANT_DEPTH = 8;
6
6
  const MAX_DESCENDANT_PIDS = 512;
7
7
  const PROCESS_EXIT_POLL_MS = 10;
8
8
  const PROCESS_SNAPSHOT_TIMEOUT_MS = 1_000;
9
- const PROCESS_IDENTITY_PROBE_TIMEOUT_MS = 250;
9
+ const PROCESS_IDENTITY_PROBE_TIMEOUT_MS = 1_000;
10
10
  const PROBE_TIMEOUT = Symbol("process-probe-timeout");
11
11
  /**
12
12
  * Capture descendant ancestry and reusable identities in the same bounded
@@ -23,6 +23,18 @@ export async function collectDescendantIdentities(rootPid) {
23
23
  }
24
24
  return snapshot;
25
25
  }
26
+ /** Return current members of the selected process groups, or undefined when unreadable. */
27
+ export async function collectProcessGroupsIdentities(processGroupIds) {
28
+ if (process.platform === "win32")
29
+ return undefined;
30
+ const groups = new Set(processGroupIds.filter((id) => Number.isInteger(id) && id > 0));
31
+ if (groups.size === 0)
32
+ return [];
33
+ const rows = await raceValueWithTimeout(collectProcessSnapshotRows(), PROCESS_SNAPSHOT_TIMEOUT_MS);
34
+ if (rows === PROBE_TIMEOUT || rows === undefined)
35
+ return undefined;
36
+ return rows.filter((row) => groups.has(row.processGroupId)).map(toProcessIdentity);
37
+ }
26
38
  /**
27
39
  * Compatibility surface for callers that only need numeric process listings.
28
40
  * Cancellation must use collectDescendantIdentities so ancestry and identity
@@ -36,15 +48,21 @@ export async function collectDescendants(rootPid) {
36
48
  return [];
37
49
  }
38
50
  }
39
- /** Read one reusable process identity. PID alone is not stable across teardown. */
40
- export async function readProcessIdentity(pid) {
51
+ /** Distinguish a missing process from a failed or timed-out identity probe. */
52
+ export async function probeProcessIdentity(pid) {
41
53
  if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32")
42
- return undefined;
54
+ return { complete: true };
43
55
  const read = process.platform === "linux"
44
56
  ? readProcessIdentityBatchFromProc([pid])
45
57
  : readProcessIdentityBatchWithPs([pid]);
46
58
  const batch = await raceValueWithTimeout(read, PROCESS_IDENTITY_PROBE_TIMEOUT_MS);
47
- return batch === PROBE_TIMEOUT ? undefined : batch.identities.get(pid);
59
+ if (batch === PROBE_TIMEOUT)
60
+ return { complete: false };
61
+ return { identity: batch.identities.get(pid), complete: batch.complete };
62
+ }
63
+ /** Read one reusable process identity. PID alone is not stable across teardown. */
64
+ export async function readProcessIdentity(pid) {
65
+ return (await probeProcessIdentity(pid)).identity;
48
66
  }
49
67
  /**
50
68
  * Revalidate all captured identities concurrently under one deadline. The
@@ -63,16 +81,26 @@ export async function revalidateProcessIdentities(expectedIdentities, options =
63
81
  if (batch === PROBE_TIMEOUT)
64
82
  return { identities: [], complete: false };
65
83
  const identities = [];
84
+ const groupChanged = [];
66
85
  for (const expectedIdentity of expected) {
67
86
  const current = batch.identities.get(expectedIdentity.pid);
68
- if (current !== undefined &&
69
- current.startToken === expectedIdentity.startToken &&
70
- current.processGroupId === expectedIdentity.processGroupId &&
71
- !isQuiescentState(current.state)) {
87
+ if (current === undefined ||
88
+ current.startToken !== expectedIdentity.startToken ||
89
+ isQuiescentState(current.state)) {
90
+ continue;
91
+ }
92
+ if (current.processGroupId !== expectedIdentity.processGroupId) {
93
+ groupChanged.push(current);
94
+ }
95
+ else {
72
96
  identities.push(current);
73
97
  }
74
98
  }
75
- return { identities, complete: batch.complete };
99
+ return {
100
+ identities,
101
+ complete: batch.complete,
102
+ ...(groupChanged.length > 0 ? { groupChanged } : {}),
103
+ };
76
104
  }
77
105
  /** Return the current identity only when pid, start token, and PGID still match. */
78
106
  export async function revalidateProcessIdentity(expected, identityReader) {
@@ -104,7 +132,7 @@ export async function waitForProcessTreeExit(identities, timeoutMs, options = {}
104
132
  identityReader: options.identityReader,
105
133
  timeoutMs: probeTimeoutMs,
106
134
  });
107
- active = !validation.complete || validation.identities.length > 0;
135
+ active = !validation.complete || validation.identities.length > 0 || (validation.groupChanged?.length ?? 0) > 0;
108
136
  }
109
137
  if (!active)
110
138
  return true;
@@ -117,13 +145,13 @@ export async function waitForProcessTreeExit(identities, timeoutMs, options = {}
117
145
  async function collectVerifiedDescendantIdentities(rootPid) {
118
146
  const initialRows = await collectProcessSnapshotRows();
119
147
  if (initialRows === undefined)
120
- return [];
148
+ throw new Error("process snapshot unavailable");
121
149
  const candidateRows = descendantRowsFromSnapshot(rootPid, initialRows);
122
150
  const initialRoot = initialRows.find((row) => row.pid === rootPid);
123
151
  if (initialRoot === undefined || candidateRows.length === 0)
124
152
  return [];
125
- // A second, targeted snapshot closes the cross-row /proc race: every child
126
- // identity and every numeric parent edge must still match before acceptance.
153
+ // A second, targeted snapshot closes the cross-row /proc race: each child
154
+ // must retain its PID and start token. Reparenting and PGID changes are not exit.
127
155
  const expectedRows = [initialRoot, ...candidateRows];
128
156
  const expectedByPid = new Map(expectedRows.map((row) => [row.pid, row]));
129
157
  const verificationRows = await readCurrentSnapshotRows(expectedRows);
@@ -131,11 +159,9 @@ async function collectVerifiedDescendantIdentities(rootPid) {
131
159
  const expected = expectedByPid.get(current.pid);
132
160
  return (expected !== undefined &&
133
161
  current.startToken === expected.startToken &&
134
- current.processGroupId === expected.processGroupId &&
135
- current.parentPid === expected.parentPid &&
136
162
  !isQuiescentState(current.state));
137
163
  });
138
- return descendantRowsFromSnapshot(rootPid, stableRows).map(toProcessIdentity);
164
+ return stableRows.map(toProcessIdentity);
139
165
  }
140
166
  function descendantRowsFromSnapshot(rootPid, rows) {
141
167
  const root = rows.find((row) => row.pid === rootPid);
@@ -186,6 +212,9 @@ async function readCurrentSnapshotRows(expectedRows) {
186
212
  Promise.all(procPids.map((pid) => readProcRow(pid))),
187
213
  collectProcessSnapshotRowsFromPs(psPids),
188
214
  ]);
215
+ if (procOutcomes.some((outcome) => outcome.status === "error") || (psPids.length > 0 && psRows === undefined)) {
216
+ throw new Error("process identity verification failed");
217
+ }
189
218
  return [
190
219
  ...procOutcomes.flatMap((outcome) => outcome.status === "found" ? [outcome.row] : []),
191
220
  ...(psRows ?? []),
@@ -212,6 +241,8 @@ async function collectProcessSnapshotRowsFromProc() {
212
241
  const outcomes = await Promise.all(entries
213
242
  .filter((entry) => /^\d+$/.test(entry))
214
243
  .map((entry) => readProcRow(Number.parseInt(entry, 10))));
244
+ if (outcomes.some((outcome) => outcome.status === "error"))
245
+ return undefined;
215
246
  return outcomes.flatMap((outcome) => outcome.status === "found" ? [outcome.row] : []);
216
247
  }
217
248
  async function collectProcessSnapshotRowsFromPs(pids) {