kimi-plugin-cc 2.4.1 → 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 (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +9 -0
  4. package/README.md +4 -4
  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 +50 -53
  10. package/dist/companion.js +5 -1
  11. package/dist/git.js +5 -3
  12. package/dist/help.js +3 -2
  13. package/dist/job-store.js +13 -0
  14. package/dist/jobs.js +3 -0
  15. package/dist/kimi-errors.js +27 -21
  16. package/dist/parsing.js +1 -0
  17. package/dist/prompt-size.js +18 -0
  18. package/dist/swarm-activation.js +21 -0
  19. package/dist/swarm-recovery.js +134 -0
  20. package/dist/version.js +1 -1
  21. package/docs/registry-releases.md +8 -1
  22. package/docs/safety.md +3 -1
  23. package/docs/swarm-recovery.md +33 -0
  24. package/docs/windows-validation.md +39 -0
  25. package/package.json +6 -3
  26. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  27. package/plugins/kimi-codex/dist/cli-client.js +17 -7
  28. package/plugins/kimi-codex/dist/commands/cancel.js +14 -3
  29. package/plugins/kimi-codex/dist/commands/recover-swarm.js +135 -0
  30. package/plugins/kimi-codex/dist/commands/status.js +1 -1
  31. package/plugins/kimi-codex/dist/commands/swarm.js +50 -53
  32. package/plugins/kimi-codex/dist/companion.js +5 -1
  33. package/plugins/kimi-codex/dist/git.js +5 -3
  34. package/plugins/kimi-codex/dist/help.js +3 -2
  35. package/plugins/kimi-codex/dist/job-store.js +13 -0
  36. package/plugins/kimi-codex/dist/jobs.js +3 -0
  37. package/plugins/kimi-codex/dist/kimi-errors.js +27 -21
  38. package/plugins/kimi-codex/dist/parsing.js +1 -0
  39. package/plugins/kimi-codex/dist/prompt-size.js +18 -0
  40. package/plugins/kimi-codex/dist/swarm-activation.js +21 -0
  41. package/plugins/kimi-codex/dist/swarm-recovery.js +134 -0
  42. package/plugins/kimi-codex/dist/version.js +1 -1
@@ -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";
@@ -30,22 +33,6 @@ import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js
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) {
@@ -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) => {
@@ -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
  }
@@ -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.";
@@ -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
  }
@@ -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
  }
@@ -44,6 +44,11 @@ export function classifyManagedCommandFailure(error, commandType, jobId, options
44
44
  const details = { availability: classification.kind };
45
45
  if (error instanceof RuntimeError) {
46
46
  details.cause_code = error.code;
47
+ for (const key of ["spawn_code", "spawn_errno", "spawn_message"]) {
48
+ if (error.details[key] !== undefined)
49
+ details[key] = key === "spawn_message" && typeof error.details[key] === "string"
50
+ ? truncateCauseMessageForDetails(error.details[key]) : error.details[key];
51
+ }
47
52
  if (error.code === "RESPONSE_TIMEOUT") {
48
53
  for (const key of ["budget_ms", "record_count", "partial_output_chars", "aborted", "settled_post_abort", "hook_escapes"]) {
49
54
  if (error.details[key] !== undefined)
@@ -129,28 +134,29 @@ function classifyKimiAvailability(error) {
129
134
  authProbe: "failed",
130
135
  };
131
136
  }
132
- if (error instanceof RuntimeError && error.code === "CLI_SPAWN_FAILED") {
133
- return {
134
- kind: "binary_unavailable",
135
- summary: "the Kimi CLI is missing from PATH or not executable in this environment.",
136
- nextStep: "Run `/kimi:setup` to verify the install, then expose `kimi` on PATH and retry.",
137
- runtimeProbe: "failed",
138
- authProbe: "failed",
139
- };
140
- }
141
- // v1.0 cli-client surfaces async ENOENT (Bun) as CLI_PROCESS_ERROR with
142
- // "spawn ... ENOENT" in the message. Map that to binary_unavailable so
143
- // the failure message points users at /kimi:setup.
144
137
  if (error instanceof RuntimeError &&
145
- error.code === "CLI_PROCESS_ERROR" &&
146
- /\bENOENT\b/.test(message)) {
147
- return {
148
- kind: "binary_unavailable",
149
- summary: "the Kimi CLI is missing from PATH or not executable in this environment.",
150
- nextStep: "Run `/kimi:setup` to verify the install, then expose `kimi` on PATH and retry.",
151
- runtimeProbe: "failed",
152
- authProbe: "failed",
153
- };
138
+ (error.code === "CLI_SPAWN_FAILED" || error.code === "CLI_PROCESS_ERROR")) {
139
+ const spawnCode = error.details.spawn_code;
140
+ const binaryUnavailable = typeof spawnCode === "string" && ["ENOENT", "EACCES", "ENOEXEC"].includes(spawnCode);
141
+ if (typeof spawnCode === "string" && !binaryUnavailable) {
142
+ return {
143
+ kind: "startup_failed",
144
+ summary: "the Kimi subprocess could not be started or used.",
145
+ nextStep: "Check the underlying process error; shorten the prompt or environment if an argument-size limit was exceeded, then retry.",
146
+ runtimeProbe: "failed",
147
+ authProbe: "failed",
148
+ };
149
+ }
150
+ // Keep opaque synchronous failures and legacy async ENOENT diagnostics compatible.
151
+ if (binaryUnavailable || error.code === "CLI_SPAWN_FAILED" || /\bENOENT\b/.test(message)) {
152
+ return {
153
+ kind: "binary_unavailable",
154
+ summary: "the Kimi CLI is missing from PATH or not executable in this environment.",
155
+ nextStep: "Run `/kimi:setup` to verify the install, then expose `kimi` on PATH and retry.",
156
+ runtimeProbe: "failed",
157
+ authProbe: "failed",
158
+ };
159
+ }
154
160
  }
155
161
  // v1.0 cli-client surfaces non-ENOENT process errors and non-zero
156
162
  // exits as CLI_PROCESS_ERROR / CLI_NONZERO_EXIT. Treat both as
@@ -18,6 +18,7 @@ export const RESCUE_SUPPORTED_FLAGS = "-m/--model <name>, --thinking <off|low|me
18
18
  export const REVIEW_SUPPORTED_FLAGS = "--base <ref>, -m/--model <name>, --thinking <off|low|medium|high>, --budget <30m|1h|90s>";
19
19
  export const JOB_LOOKUP_SUPPORTED_FLAGS = "--type <review|challenge|rescue|review_gate|ask>, --json";
20
20
  export const SETUP_SUPPORTED_FLAGS = "--check, --uninstall, --all (with --uninstall), --enable-review-gate, --disable-review-gate, --models [--json] (without other setup flags)";
21
+ export const RECOVER_SWARM_SUPPORTED_FLAGS = "--apply";
21
22
  export const REPAIR_SESSIONS_SUPPORTED_FLAGS = "--apply, --all";
22
23
  export const REPLAY_SUPPORTED_FLAGS = "none (one job-id required)";
23
24
  /** Validate syntax only; upstream per-model supportEfforts remains authoritative. */
@@ -0,0 +1,18 @@
1
+ import { RuntimeError } from "./errors.js";
2
+ // Node/libuv's default CreateProcess argument quoting (UTF-16 code units).
3
+ export function windowsCommandLineLength(command, args) {
4
+ const quotedLength = (value) => {
5
+ if (value.length > 0 && !/[\s"]/u.test(value))
6
+ return value.length;
7
+ return 2 + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1').length;
8
+ };
9
+ return [command, ...args].reduce((size, value) => size + quotedLength(value) + 1, 0);
10
+ }
11
+ export function assertPromptFitsCommandLine(command, args, platform = process.platform) {
12
+ if (platform !== "win32")
13
+ return;
14
+ const size = windowsCommandLineLength(command, args);
15
+ if (size <= 32_767)
16
+ return;
17
+ throw new RuntimeError("CLI_COMMAND_LINE_TOO_LONG", "The Kimi prompt exceeds the Windows command-line limit; the CLI was not started. Narrow the review or split it into smaller scopes. A short ask prompt can reference a saved diff inside the workspace. Reinstalling Kimi or running setup will not fix this. The current print transport does not support stdin prompts.", "cli-client.pre-spawn", { details: { command_line_utf16_units: size, limit_utf16_units: 32_767, platform } });
18
+ }
@@ -39,6 +39,7 @@ export async function preactivateSwarmSession(options) {
39
39
  await verifyMeta(web, prepared.expectedVersion, options.signal, failureCode);
40
40
  const model = await resolveProfileModel(web, options, failureCode);
41
41
  identity = await createSession(web, options, failureCode);
42
+ options.onRecoveryEvent?.({ kind: "session", ...identity });
42
43
  profileMutationAttempted = true;
43
44
  await postSwarmProfile(web, identity, true, model, options.signal, failureCode);
44
45
  await verifySwarmStatus(web, identity, true, model, options.signal, failureCode);
@@ -88,6 +89,7 @@ export async function deactivateSwarmSession(options) {
88
89
  await verifySwarmStatus(web, options, false, undefined, options.signal, failureCode);
89
90
  await shutdownWeb(web, options.signal, failureCode);
90
91
  await requireSwarmJournal(prepared.kimiHome, options, "exit", options.signal, failureCode);
92
+ options.onRecoveryEvent?.({ kind: "disabled" });
91
93
  throwIfAborted(options.signal, failureCode, "swarm-deactivation.complete");
92
94
  }
93
95
  catch (error) {
@@ -144,6 +146,7 @@ async function startWebChild(options, env, failureCode) {
144
146
  "error",
145
147
  ];
146
148
  let child;
149
+ options.onRecoveryEvent?.({ kind: "web-spawn" });
147
150
  try {
148
151
  child = spawn(options.command, args, {
149
152
  cwd: options.cwd,
@@ -153,6 +156,7 @@ async function startWebChild(options, env, failureCode) {
153
156
  });
154
157
  }
155
158
  catch {
159
+ options.onRecoveryEvent?.({ kind: "web-exit" });
156
160
  throw failure(failureCode, "swarm-activation.spawn", "Failed to spawn the Kimi web lifecycle process.");
157
161
  }
158
162
  let exited = false;
@@ -184,6 +188,7 @@ async function startWebChild(options, env, failureCode) {
184
188
  child,
185
189
  closePromise,
186
190
  descendants: [],
191
+ onRecoveryEvent: options.onRecoveryEvent,
187
192
  trackingStopped: false,
188
193
  lastSnapshotComplete: false,
189
194
  snapshotIncomplete: false,
@@ -198,6 +203,8 @@ async function startWebChild(options, env, failureCode) {
198
203
  try {
199
204
  const ready = await waitForReadyLine(owned, options.signal, failureCode);
200
205
  await refreshOwnedDescendants(owned);
206
+ if (owned.recoveryFailed)
207
+ throw failure(failureCode, "swarm-activation.recovery", "Cannot persist web ownership; refusing session mutation.");
201
208
  return Object.assign(owned, ready);
202
209
  }
203
210
  catch (error) {
@@ -610,12 +617,26 @@ async function refreshOwnedDescendants(owned) {
610
617
  owned.snapshotIncomplete = true;
611
618
  owned.lastSnapshotComplete = false;
612
619
  }
620
+ try {
621
+ owned.onRecoveryEvent?.({ kind: "web-processes", identities: [...(owned.rootIdentity ? [owned.rootIdentity] : []), ...owned.descendants], complete: owned.lastSnapshotComplete });
622
+ }
623
+ catch {
624
+ owned.recoveryFailed = true;
625
+ }
613
626
  })().finally(() => {
614
627
  owned.refreshPromise = undefined;
615
628
  });
616
629
  return await owned.refreshPromise;
617
630
  }
618
631
  async function teardownOwnedChild(owned) {
632
+ const quiescent = await teardownOwnedChildInner(owned);
633
+ if (quiescent && !owned.recoveryFailed) {
634
+ owned.onRecoveryEvent?.({ kind: "web-exit" });
635
+ return true;
636
+ }
637
+ return false;
638
+ }
639
+ async function teardownOwnedChildInner(owned) {
619
640
  await stopDescendantTracking(owned);
620
641
  const childPid = owned.child.pid;
621
642
  if (childPid === undefined)