kimi-plugin-cc 2.4.0 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +18 -0
  4. package/README.md +17 -9
  5. package/dist/cli-client.js +19 -9
  6. package/dist/commands/cancel.js +14 -3
  7. package/dist/commands/cli-helpers.js +5 -1
  8. package/dist/commands/recover-swarm.js +135 -0
  9. package/dist/commands/status.js +1 -1
  10. package/dist/commands/swarm.js +241 -87
  11. package/dist/companion.js +5 -1
  12. package/dist/git.js +5 -3
  13. package/dist/help.js +3 -2
  14. package/dist/hooks/config-safety.js +4 -3
  15. package/dist/job-store.js +13 -0
  16. package/dist/jobs.js +3 -0
  17. package/dist/kimi-engine.js +1 -0
  18. package/dist/kimi-errors.js +27 -21
  19. package/dist/parsing.js +1 -0
  20. package/dist/process-tree.js +48 -17
  21. package/dist/prompt-size.js +18 -0
  22. package/dist/swarm-activation.js +833 -0
  23. package/dist/swarm-recovery.js +134 -0
  24. package/dist/version.js +1 -1
  25. package/docs/migration.md +12 -2
  26. package/docs/registry-releases.md +13 -2
  27. package/docs/safety.md +20 -6
  28. package/docs/swarm-recovery.md +33 -0
  29. package/docs/windows-validation.md +39 -0
  30. package/package.json +7 -4
  31. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  32. package/plugins/kimi-codex/dist/cli-client.js +19 -9
  33. package/plugins/kimi-codex/dist/commands/cancel.js +14 -3
  34. package/plugins/kimi-codex/dist/commands/cli-helpers.js +5 -1
  35. package/plugins/kimi-codex/dist/commands/recover-swarm.js +135 -0
  36. package/plugins/kimi-codex/dist/commands/status.js +1 -1
  37. package/plugins/kimi-codex/dist/commands/swarm.js +241 -87
  38. package/plugins/kimi-codex/dist/companion.js +5 -1
  39. package/plugins/kimi-codex/dist/git.js +5 -3
  40. package/plugins/kimi-codex/dist/help.js +3 -2
  41. package/plugins/kimi-codex/dist/hooks/config-safety.js +4 -3
  42. package/plugins/kimi-codex/dist/job-store.js +13 -0
  43. package/plugins/kimi-codex/dist/jobs.js +3 -0
  44. package/plugins/kimi-codex/dist/kimi-engine.js +1 -0
  45. package/plugins/kimi-codex/dist/kimi-errors.js +27 -21
  46. package/plugins/kimi-codex/dist/parsing.js +1 -0
  47. package/plugins/kimi-codex/dist/process-tree.js +48 -17
  48. package/plugins/kimi-codex/dist/prompt-size.js +18 -0
  49. package/plugins/kimi-codex/dist/swarm-activation.js +833 -0
  50. package/plugins/kimi-codex/dist/swarm-recovery.js +134 -0
  51. package/plugins/kimi-codex/dist/version.js +1 -1
@@ -1,18 +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
- import { runCliPromptWithBudget } from "../cli-client.js";
8
+ import { ResponseTimeoutError, runCliPromptWithBudget } from "../cli-client.js";
9
+ import { deactivateSwarmSession, preactivateSwarmSession, requiresSwarmPreactivation, } from "../swarm-activation.js";
6
10
  import { executionPlanFromPersisted, observedExecutionFields, persistedExecutionPlanFields, prepareKimiExecutionPlan, } from "../kimi-engine.js";
7
11
  import { RuntimeError } from "../errors.js";
8
- import { captureWorktreePatch, createEphemeralWorktree, hasBornHead, isWorkingTreeDirty, pruneWorktrees, removeWorktree, resolveRepoIdentity, } from "../git.js";
12
+ import { captureWorktreePatch, createEphemeralWorktree, hasBornHead, isWorkingTreeDirty, removeWorktree, resolveRepoIdentity, } from "../git.js";
9
13
  import { digestPrompt, markJobCancelled, markJobFailed, sweepStaleJobs } from "../jobs.js";
10
14
  import { JobStore } from "../job-store.js";
11
15
  import { classifyManagedCommandFailure } from "../kimi-errors.js";
12
16
  import { KIMI_SWARM_DEFAULT_BUDGET_MS } from "../kimi-timeouts.js";
13
17
  import { writeInvocationLogHeader } from "../logging.js";
14
18
  import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
15
- import { MAX_DURATION_MS, parseSwarmArgs } from "../parsing.js";
19
+ import { parseSwarmArgs } from "../parsing.js";
16
20
  import { readArtifact, renderManagedJobOutput, writeArtifact } from "../render.js";
17
21
  import { hookRefusalDetails, hookRefusalRetryProtocol, maybeWarnHookMissing, verifyHookInstalled, } from "../hooks/install.js";
18
22
  import { assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing, warnHookEscapes } from "./cli-helpers.js";
@@ -24,26 +28,11 @@ import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js
24
28
  // only the wall-clock hard bound. Read/write modes reuse review/rescue lineage,
25
29
  // and the mandatory PreToolUse hook enforces read-only policy or trusted-worktree
26
30
  // write confinement on the coordinator and every subagent. Write mode requires
27
- // kimi-code 0.18.0+.
31
+ // kimi-code 0.18.0+. Exact 2.3.2 additionally activates a same-job session through
32
+ // a bounded loopback web lifecycle, then resumes it through the normal print path.
28
33
  const SWARM_SUMMARY_MAX = 120;
29
34
  const SWARM_AGENT_PROFILE = "<swarm>";
30
35
  const SWARM_WRITE_AGENT_PROFILE = "<swarm-write>";
31
- /**
32
- * Orphaned write-swarm worktrees older than this are reaped by the startup sweep.
33
- * Every normal run removes its own worktree in a finally; orphans can follow a
34
- * hard kill or failed cleanup.
35
- *
36
- * The sweep's only liveness signal is the worktree dir's mtime, which is frozen
37
- * at `git worktree add` time when a run edits only EXISTING files (in-place edits
38
- * never bump the containing dir's mtime). A live run can therefore look "idle" for
39
- * its entire wall-clock life. So the TTL MUST exceed the longest a run can live —
40
- * the hard `--budget` ceiling, `MAX_DURATION_MS` (24h) — or a concurrent run's
41
- * sweep could `git worktree remove --force` a still-live worktree out from under
42
- * its owner (ENOENT writes, lost patch). The +2h margin covers budget-expiry
43
- * teardown (AbortController fires → the finally removes the worktree well within
44
- * it). Orphans are still reaped, just after this longer, provably-safe delay.
45
- */
46
- export const WORKTREE_ORPHAN_TTL_MS = MAX_DURATION_MS + 2 * 60 * 60 * 1000;
47
36
  /**
48
37
  * Default concurrency setting when the user passes no `--max-concurrency`.
49
38
  * The plugin always exports KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY; kimi-code
@@ -87,7 +76,7 @@ export function resolveSwarmWriteMaxConcurrency(requested) {
87
76
  * subagent, so slimming this text does not move the safety boundary. The optional
88
77
  * `cap` is a SOFT subagent-count hint (the hook cannot enforce a count). The HARD
89
78
  * concurrency ceiling is separate: --max-concurrency →
90
- * KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY (see executeSwarmJob / cli-client buildEnv).
79
+ * KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY (see executeSwarmJob / cli-client buildKimiCliEnvironment).
91
80
  * The --budget wall-clock ceiling is the always-on hard bound on the whole run.
92
81
  */
93
82
  export function buildSwarmPrompt(objective, cap) {
@@ -252,7 +241,7 @@ async function runWriteSwarm(parsed, objective, context) {
252
241
  if (!(await hasBornHead(context.cwd))) {
253
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");
254
243
  }
255
- await sweepStaleWorktrees(paths, repoIdentity);
244
+ // Abandoned trees are recovery evidence, never age-based garbage.
256
245
  if (await isWorkingTreeDirty(context.cwd)) {
257
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");
258
247
  }
@@ -343,7 +332,7 @@ async function runWriteSwarm(parsed, objective, context) {
343
332
  }
344
333
  finally {
345
334
  if (preserveWorktree) {
346
- context.stderr.write(`[kimi-plugin-cc] swarm-write patch capture failed; worktree retained at ${worktreePath}.\n`);
335
+ context.stderr.write(`[kimi-plugin-cc] swarm-write patch capture or session cleanup failed; worktree retained at ${worktreePath}.\n`);
347
336
  }
348
337
  else {
349
338
  await removeWorktree(repoIdentity.repoRoot, worktreePath);
@@ -360,37 +349,6 @@ function assertWriteSwarmPreconditions(repoIdentity) {
360
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");
361
350
  }
362
351
  }
363
- /**
364
- * Reap orphaned write-swarm worktrees from prior crashed runs. Normal runs remove
365
- * their own worktree in a finally; orphans can follow hard kills or failed cleanup.
366
- * Prune stale admin entries (current repo), then force-remove on-disk worktree
367
- * dirs older than the TTL. Best-effort — never blocks the run.
368
- */
369
- async function sweepStaleWorktrees(paths, repoIdentity) {
370
- await pruneWorktrees(repoIdentity.repoRoot);
371
- // Only sweep THIS repo's namespaced subdir, so we never `rm` a worktree that a
372
- // different repo still considers live (and never leave that repo a dangling
373
- // admin entry). repoId is the realpath-stable hash from resolveRepoIdentity.
374
- const repoWorktreesDir = path.join(paths.worktreesDir, repoIdentity.repoId);
375
- const entries = await readdir(repoWorktreesDir, { withFileTypes: true }).catch(() => null);
376
- if (entries === null)
377
- return; // not created yet — nothing to sweep.
378
- const now = Date.now();
379
- for (const entry of entries) {
380
- if (!entry.isDirectory() || !entry.name.startsWith("swarm-write-"))
381
- continue;
382
- const full = path.join(repoWorktreesDir, entry.name);
383
- try {
384
- const st = await stat(full);
385
- if (now - st.mtimeMs > WORKTREE_ORPHAN_TTL_MS) {
386
- await removeWorktree(repoIdentity.repoRoot, full);
387
- }
388
- }
389
- catch {
390
- // best-effort per entry
391
- }
392
- }
393
- }
394
352
  /**
395
353
  * Capture the worktree change set as a `.patch` artifact (write mode). Returns
396
354
  * the artifact path + size, or null on capture failure. Called on EVERY terminal
@@ -465,44 +423,183 @@ writeMode) {
465
423
  }
466
424
  }
467
425
  const handlers = createCliCancellationHandlers();
426
+ const budgetDeadline = Date.now() + budgetMs;
427
+ let activationOptions;
428
+ let preactivated;
429
+ let deactivationRequired = false;
430
+ let cleanupAttempted = false;
431
+ let cleanupFailure;
432
+ let finalText;
433
+ let recovery;
434
+ const commandLabel = writeMode ? "swarm-write" : "swarm";
435
+ const recoveryCleanupTimeoutMs = 2_000;
436
+ const cleanupSignal = (timeoutMs) => AbortSignal.any([
437
+ handlers.signal,
438
+ AbortSignal.timeout(Math.max(1, timeoutMs)),
439
+ ]);
440
+ const attemptDeactivation = async (identity, fallbackStage, timeoutMs = Math.max(1, budgetDeadline - Date.now())) => {
441
+ cleanupAttempted = true;
442
+ try {
443
+ await deactivateSwarmSession({
444
+ ...activationOptions,
445
+ ...identity,
446
+ signal: cleanupSignal(timeoutMs),
447
+ });
448
+ deactivationRequired = false;
449
+ return undefined;
450
+ }
451
+ catch (error) {
452
+ return error instanceof RuntimeError
453
+ ? error
454
+ : new RuntimeError("SWARM_SESSION_CLEANUP_FAILED", "Swarm session cleanup failed without a safe diagnostic payload.", fallbackStage);
455
+ }
456
+ };
468
457
  try {
469
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
+ }
467
+ activationOptions = {
468
+ cwd: writeMode?.spawnCwd ?? job.cwd,
469
+ env: context.env,
470
+ command: executionPlan.command,
471
+ prefixArgs: executionPlan.prefixArgs,
472
+ executionPlan,
473
+ commandLabel,
474
+ model: job.model ?? undefined,
475
+ thinkingEffort: job.thinking_effort ?? undefined,
476
+ swarmMaxConcurrency: maxConcurrency,
477
+ trustedWorkspaceRoot: writeMode?.workspaceRoot,
478
+ expectedVersion: executionPlan.kimiVersion ?? undefined,
479
+ onRecoveryEvent: recovery ? (event) => recordRecoveryEvent(store, recovery, event) : undefined,
480
+ };
481
+ if (requiresSwarmPreactivation(executionPlan.kimiVersion)) {
482
+ store.updateRunningJob(job.job_id, { phase: "turn-running" });
483
+ const activationController = new AbortController();
484
+ const onParentAbort = () => activationController.abort();
485
+ let activationTimedOut = false;
486
+ handlers.signal.addEventListener("abort", onParentAbort, { once: true });
487
+ if (handlers.signal.aborted)
488
+ onParentAbort();
489
+ const activationTimer = setTimeout(() => {
490
+ activationTimedOut = true;
491
+ activationController.abort();
492
+ }, Math.max(1, budgetDeadline - Date.now()));
493
+ activationTimer.unref();
494
+ try {
495
+ preactivated = await preactivateSwarmSession({
496
+ ...activationOptions,
497
+ signal: activationController.signal,
498
+ });
499
+ deactivationRequired = true;
500
+ store.updateRunningJob(job.job_id, {
501
+ kimi_session_id: preactivated.sessionId,
502
+ kimi_session_id_source: "preactivated",
503
+ });
504
+ }
505
+ catch (error) {
506
+ const activationDidTimeOut = activationTimedOut;
507
+ clearTimeout(activationTimer);
508
+ handlers.signal.removeEventListener("abort", onParentAbort);
509
+ const details = error instanceof RuntimeError ? error.details : {};
510
+ const sessionId = typeof details.session_id === "string" ? details.session_id : undefined;
511
+ const workspaceId = typeof details.workspace_id === "string" ? details.workspace_id : undefined;
512
+ const hasSessionIdentity = sessionId !== undefined && workspaceId !== undefined;
513
+ if (error instanceof RuntimeError &&
514
+ error.code === "SWARM_SESSION_CLEANUP_FAILED" &&
515
+ details.process_cleanup_unverified === true) {
516
+ cleanupFailure = error;
517
+ }
518
+ if (hasSessionIdentity) {
519
+ store.updateRunningJob(job.job_id, {
520
+ kimi_session_id: sessionId,
521
+ kimi_session_id_source: "preactivated",
522
+ });
523
+ }
524
+ if (hasSessionIdentity && !handlers.cancelling) {
525
+ const sessionCleanupFailure = await attemptDeactivation({ sessionId, workspaceId }, "swarm.activation-cleanup", recoveryCleanupTimeoutMs);
526
+ if (sessionCleanupFailure)
527
+ cleanupFailure = sessionCleanupFailure;
528
+ }
529
+ else if (hasSessionIdentity && cleanupFailure === undefined) {
530
+ 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 } });
531
+ }
532
+ if (activationDidTimeOut && !handlers.cancelling) {
533
+ throw new ResponseTimeoutError(budgetMs, "swarm.activation", commandLabel, undefined, error);
534
+ }
535
+ throw error;
536
+ }
537
+ finally {
538
+ clearTimeout(activationTimer);
539
+ handlers.signal.removeEventListener("abort", onParentAbort);
540
+ }
541
+ }
542
+ const promptBudgetMs = budgetDeadline - Date.now();
543
+ if (promptBudgetMs <= 0)
544
+ throw new ResponseTimeoutError(budgetMs, "swarm.prompt", commandLabel);
470
545
  store.updateRunningJob(job.job_id, { phase: "turn-running" });
546
+ if (recovery)
547
+ recordRecoveryEvent(store, recovery, { kind: "print-spawn" });
471
548
  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,
549
+ cwd: activationOptions.cwd,
476
550
  env: context.env,
477
551
  command: executionPlan.command,
478
552
  prefixArgs: [...executionPlan.prefixArgs],
479
553
  executionPlan,
480
554
  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.
555
+ commandLabel,
489
556
  swarmMaxConcurrency: maxConcurrency,
490
- // Trusted allowlist root for the swarm-write hook (forge-proof; ignored
491
- // by other labels). Only set in write mode.
492
557
  trustedWorkspaceRoot: writeMode?.workspaceRoot,
493
558
  model: job.model ?? undefined,
494
559
  thinkingEffort: job.thinking_effort ?? undefined,
560
+ resumeSessionId: preactivated?.sessionId,
495
561
  logPath: job.stream_log_path,
496
- 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
+ },
497
569
  signal: handlers.signal,
498
- }, budgetMs, "swarm.prompt");
570
+ }, promptBudgetMs, "swarm.prompt");
571
+ if (recovery)
572
+ recordRecoveryEvent(store, recovery, { kind: "print-exit" });
499
573
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
574
+ finalText = reassembleProseFromRecords(result.records);
575
+ let resultError;
500
576
  if (handlers.cancelling) {
501
- throw new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
577
+ resultError = new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
578
+ }
579
+ else {
580
+ try {
581
+ assertCliResultSuccess(result, "swarm.runtime");
582
+ if (preactivated && result.sessionId !== preactivated.sessionId) {
583
+ 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 } });
584
+ }
585
+ }
586
+ catch (error) {
587
+ resultError = error;
588
+ }
502
589
  }
503
- assertCliResultSuccess(result, "swarm.runtime");
504
590
  warnHookEscapes(result, "swarm", context.stderr);
505
- if (result.sessionId !== undefined &&
591
+ if (preactivated) {
592
+ cleanupFailure = await attemptDeactivation(preactivated, "swarm.cleanup");
593
+ }
594
+ if (resultError !== undefined)
595
+ throw resultError;
596
+ if (handlers.cancelling) {
597
+ throw new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
598
+ }
599
+ if (cleanupFailure)
600
+ throw cleanupFailure;
601
+ if (!preactivated &&
602
+ result.sessionId !== undefined &&
506
603
  result.sessionId.length > 0 &&
507
604
  result.sessionId !== job.kimi_session_id) {
508
605
  store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
@@ -517,12 +614,14 @@ writeMode) {
517
614
  });
518
615
  warnIfSessionIdMissing(result, "swarm", job.job_id, context.stderr);
519
616
  const prose = reassembleProseFromRecords(result.records);
520
- let finalText = prose;
617
+ finalText = prose;
521
618
  if (writeMode) {
522
619
  const captured = await capturePatchArtifact(paths, job.job_id, writeMode.workspaceRoot, context.stderr, writeMode.onPatchCaptureFailure);
523
620
  if (captured === null) {
524
621
  throw new RuntimeError("SWARM_PATCH_CAPTURE_FAILED", `Swarm patch capture failed; worktree retained at ${writeMode.workspaceRoot}.`, "swarm.patch-capture", { details: { worktreePath: writeMode.workspaceRoot } });
525
622
  }
623
+ if (recovery)
624
+ recovery.patchPath = captured.path;
526
625
  finalText = `${renderWriteSwarmHeader(captured)}\n\n---\n\n${prose}`.trimEnd();
527
626
  }
528
627
  const rendered = renderManagedJobOutput(job, finalText);
@@ -541,6 +640,8 @@ writeMode) {
541
640
  if (handlers.cancelling) {
542
641
  throw new RuntimeError("SWARM_CANCELLED", "Swarm cancelled after artifact write.", "swarm.runtime");
543
642
  }
643
+ if (recovery)
644
+ finishRecoveryRecord(store, recovery);
544
645
  return (store.markCompleted(job.job_id, {
545
646
  summary: `[${writeMode ? "swarm-write" : "swarm"}] ${shorten(objective, SWARM_SUMMARY_MAX)}`,
546
647
  phase: "done",
@@ -549,46 +650,99 @@ writeMode) {
549
650
  }) ?? job);
550
651
  }
551
652
  catch (error) {
653
+ if (preactivated && deactivationRequired && activationOptions) {
654
+ if (handlers.cancelling) {
655
+ cleanupFailure = new RuntimeError("SWARM_SESSION_CLEANUP_FAILED", "Swarm session cleanup was not completed after cancellation.", "swarm.cleanup", { details: { cancelled: true, cleanup_unverified: true } });
656
+ }
657
+ else if (!cleanupAttempted) {
658
+ cleanupFailure = await attemptDeactivation(preactivated, "swarm.cleanup", recoveryCleanupTimeoutMs);
659
+ }
660
+ else if (error instanceof RuntimeError && error.code === "SWARM_SESSION_CLEANUP_FAILED") {
661
+ cleanupFailure ??= error;
662
+ }
663
+ }
664
+ if (cleanupFailure) {
665
+ writeMode?.onPatchCaptureFailure?.();
666
+ context.stderr.write(`[kimi-plugin-cc] swarm session cleanup also failed: ${cleanupFailure.message}\n`);
667
+ }
552
668
  let captureFailed = writeMode !== undefined && error instanceof RuntimeError && error.code === "SWARM_PATCH_CAPTURE_FAILED";
553
669
  if (writeMode && !(error instanceof RuntimeError && error.code === "SWARM_PATCH_CAPTURE_FAILED")) {
554
670
  const captured = await capturePatchArtifact(paths, job.job_id, writeMode.workspaceRoot, context.stderr, writeMode.onPatchCaptureFailure);
555
671
  captureFailed = captured === null;
672
+ if (recovery && captured)
673
+ recovery.patchPath = captured.path;
556
674
  if (captured && !captured.empty) {
557
675
  context.stderr.write(`[kimi-plugin-cc] swarm-write run ended early; partial patch captured at ${captured.path}.\n`);
558
676
  }
559
677
  }
560
- const recoveryDetails = captureFailed && writeMode ? { worktreePath: writeMode.workspaceRoot } : {};
678
+ const recoveryDetails = {
679
+ ...(error instanceof RuntimeError && typeof error.details.exit_code === "number"
680
+ ? { exit_code: error.details.exit_code }
681
+ : {}),
682
+ ...((captureFailed || cleanupFailure) && writeMode ? { worktreePath: writeMode.workspaceRoot } : {}),
683
+ ...(cleanupFailure
684
+ ? {
685
+ cleanup_error: {
686
+ code: cleanupFailure.code,
687
+ stage: cleanupFailure.stage,
688
+ cancelled: handlers.cancelling,
689
+ unverified: true,
690
+ },
691
+ ...(finalText !== undefined ? { rawOutput: finalText } : {}),
692
+ }
693
+ : {}),
694
+ };
561
695
  if (handlers.cancelling) {
696
+ const cancellationCause = classifyManagedCommandFailure(error, writeMode ? "rescue" : "review", job.job_id, {
697
+ preserveStage: true,
698
+ });
562
699
  const cancelledError = new RuntimeError("SWARM_CANCELLED", captureFailed && writeMode
563
700
  ? `Swarm cancelled; patch capture failed and worktree retained at ${writeMode.workspaceRoot}.`
564
701
  : "Swarm cancelled by user request.", "swarm.runtime", {
565
702
  ...(error instanceof Error ? { cause: error } : {}),
566
- details: recoveryDetails,
703
+ details: {
704
+ ...(cancellationCause instanceof RuntimeError ? cancellationCause.details : {}),
705
+ ...recoveryDetails,
706
+ },
567
707
  });
568
708
  return await markJobCancelled(store, paths, job, "Swarm cancelled by user request.", cancelledError, {
569
709
  phase: "cancelled",
570
710
  });
571
711
  }
712
+ if (error === cleanupFailure && budgetDeadline <= Date.now() && !(error instanceof ResponseTimeoutError)) {
713
+ error = new ResponseTimeoutError(budgetMs, "swarm.cleanup", commandLabel, undefined, cleanupFailure);
714
+ }
572
715
  const classified = classifyManagedCommandFailure(error, writeMode ? "rescue" : "review", job.job_id, {
573
716
  preserveStage: true,
574
717
  });
575
- if (captureFailed && writeMode) {
576
- const withRecovery = classified instanceof RuntimeError
718
+ const withRecovery = captureFailed || cleanupFailure
719
+ ? classified instanceof RuntimeError
577
720
  ? new RuntimeError(classified.code, classified.message, classified.stage, {
578
721
  cause: classified,
579
722
  details: { ...classified.details, ...recoveryDetails },
580
723
  })
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" });
724
+ : new RuntimeError(cleanupFailure?.code ?? "SWARM_PATCH_CAPTURE_FAILED", classified.message, cleanupFailure?.stage ?? "swarm.patch-capture", { cause: classified, details: recoveryDetails })
725
+ : classified;
726
+ return await markJobFailed(store, paths, job, withRecovery, "Swarm failed.", { phase: "failed" });
588
727
  }
589
728
  finally {
590
729
  handlers.dispose();
591
- 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
+ }
592
746
  }
593
747
  }
594
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) => {
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,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) {
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
  }
@@ -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([