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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +9 -0
- package/README.md +4 -4
- package/dist/cli-client.js +17 -7
- package/dist/commands/cancel.js +14 -3
- package/dist/commands/recover-swarm.js +135 -0
- package/dist/commands/status.js +1 -1
- package/dist/commands/swarm.js +50 -53
- package/dist/companion.js +5 -1
- package/dist/git.js +5 -3
- package/dist/help.js +3 -2
- package/dist/job-store.js +13 -0
- package/dist/jobs.js +3 -0
- package/dist/kimi-errors.js +27 -21
- package/dist/parsing.js +1 -0
- package/dist/prompt-size.js +18 -0
- package/dist/swarm-activation.js +21 -0
- package/dist/swarm-recovery.js +134 -0
- package/dist/version.js +1 -1
- package/docs/registry-releases.md +8 -1
- package/docs/safety.md +3 -1
- package/docs/swarm-recovery.md +33 -0
- package/docs/windows-validation.md +39 -0
- package/package.json +6 -3
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/cli-client.js +17 -7
- package/plugins/kimi-codex/dist/commands/cancel.js +14 -3
- package/plugins/kimi-codex/dist/commands/recover-swarm.js +135 -0
- package/plugins/kimi-codex/dist/commands/status.js +1 -1
- package/plugins/kimi-codex/dist/commands/swarm.js +50 -53
- package/plugins/kimi-codex/dist/companion.js +5 -1
- package/plugins/kimi-codex/dist/git.js +5 -3
- package/plugins/kimi-codex/dist/help.js +3 -2
- package/plugins/kimi-codex/dist/job-store.js +13 -0
- package/plugins/kimi-codex/dist/jobs.js +3 -0
- package/plugins/kimi-codex/dist/kimi-errors.js +27 -21
- package/plugins/kimi-codex/dist/parsing.js +1 -0
- package/plugins/kimi-codex/dist/prompt-size.js +18 -0
- package/plugins/kimi-codex/dist/swarm-activation.js +21 -0
- package/plugins/kimi-codex/dist/swarm-recovery.js +134 -0
- package/plugins/kimi-codex/dist/version.js +1 -1
|
@@ -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.
|
|
4
|
+
"version": "2.4.2",
|
|
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.2 — 2026-10-26
|
|
6
|
+
|
|
7
|
+
- Integrate community PR #22's structured spawn-error classification, preserving contributor history. Bound the additional diagnostic message. Known argument/resource exhaustion no longer receives reinstall advice.
|
|
8
|
+
- Refuse oversized Windows command lines before spawn and add a credential-free compiled-runtime Windows CI lane. This improves diagnosis, not prompt transport; issue #14 remains open for native Windows/community verification and large-review support.
|
|
9
|
+
- Add durable, preview-first `recover-swarm` for exact 2.3.2 interrupted runs, with verified process ownership, pre-mutation session identity, recovery locking, explicit uncertainty refusals and patch preservation. Stop age-based deletion of abandoned worktrees. Exact CLI certification remains unchanged through 2.3.2.
|
|
10
|
+
- Shorten the agent contract sheet and enforce its 32 KiB limit during `check`.
|
|
11
|
+
|
|
12
|
+
Thanks to **@Nitjsefnie** for PR #22.
|
|
13
|
+
|
|
5
14
|
## 2.4.1 — 2026-10-25
|
|
6
15
|
|
|
7
16
|
- Certify exact kimi-code 2.3.2 for all eight operations using the 15-test/92-assertion source scan, plan-ON/OFF controls, and the final 19-test/125-assertion production smoke. Keep the no-plan construction and legacy-v1 boundary at 0.41.x. See [evidence](docs/upstream-2.3.2-certification.md).
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ You need:
|
|
|
42
42
|
- Node.js 22.5 or newer
|
|
43
43
|
- Kimi Code CLI 2.3.2 (certified; 2.3.1 and 2.3.0 are also certified)
|
|
44
44
|
|
|
45
|
-
Windows is not currently supported. Check your versions in a terminal:
|
|
45
|
+
Windows is not currently supported. Contributors can help with the [no-model Windows diagnostics checks](./docs/windows-validation.md). Check your versions in a terminal:
|
|
46
46
|
|
|
47
47
|
```sh
|
|
48
48
|
node --version
|
|
@@ -105,10 +105,10 @@ Codex. Published releases are also available as
|
|
|
105
105
|
both compiled plugin distributions. No Bun, TypeScript, or install scripts are
|
|
106
106
|
needed to use the package. Kimi Code CLI and Node are still prerequisites.
|
|
107
107
|
|
|
108
|
-
For an explicitly versioned local Claude Code plugin, this example pins the published 2.4.
|
|
108
|
+
For an explicitly versioned local Claude Code plugin, this example pins the published 2.4.2 package:
|
|
109
109
|
|
|
110
110
|
```sh
|
|
111
|
-
npm install --prefix ~/.local/share/kimi-plugin-cc kimi-plugin-cc@2.4.
|
|
111
|
+
npm install --prefix ~/.local/share/kimi-plugin-cc kimi-plugin-cc@2.4.2
|
|
112
112
|
claude --plugin-dir ~/.local/share/kimi-plugin-cc/node_modules/kimi-plugin-cc
|
|
113
113
|
```
|
|
114
114
|
|
|
@@ -174,7 +174,7 @@ Use these commands to follow a task:
|
|
|
174
174
|
| Read the result | `/kimi:result` | `$kimi-result` |
|
|
175
175
|
| Cancel work | `/kimi:cancel` | `$kimi-cancel` |
|
|
176
176
|
|
|
177
|
-
Without a job ID, cancel stops the latest running task for this repository. If several tasks are running, check which one you want to stop first. Use cancel rather than interrupting the app so write-swarm can save its patch.
|
|
177
|
+
Without a job ID, cancel stops the latest running task for this repository. If several tasks are running, check which one you want to stop first. Use cancel rather than interrupting the app so write-swarm can save its patch. For interrupted runs created by the unreleased recovery implementation, see [preview-first swarm recovery](./docs/swarm-recovery.md); it never applies a patch or deletes abandoned work.
|
|
178
178
|
|
|
179
179
|
Canceling a task does not undo changes it has already made.
|
|
180
180
|
|
package/dist/cli-client.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
9
9
|
import path from "node:path";
|
|
10
|
+
import { assertPromptFitsCommandLine } from "./prompt-size.js";
|
|
10
11
|
import { createHookEscapeMonitor } from "./hook-escape.js";
|
|
11
12
|
import { RuntimeError, formatError } from "./errors.js";
|
|
12
13
|
import { assertNoUnsafeExperimentalSelector, assertExecutionPlanMatchesSpawn, } from "./kimi-engine.js";
|
|
@@ -29,6 +30,14 @@ const DESCENDANT_SNAPSHOT_TIMEOUT_MS = 1_000;
|
|
|
29
30
|
/** Hard bound for each batched identity revalidation pass. */
|
|
30
31
|
const IDENTITY_REVALIDATION_TIMEOUT_MS = 250;
|
|
31
32
|
const DESCENDANT_SNAPSHOT_TIMEOUT = Symbol("descendant-snapshot-timeout");
|
|
33
|
+
function spawnErrorDetails(error) {
|
|
34
|
+
const cause = error;
|
|
35
|
+
return {
|
|
36
|
+
spawn_code: cause?.code,
|
|
37
|
+
spawn_errno: cause?.errno,
|
|
38
|
+
spawn_message: formatError(error),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
32
41
|
export async function runCliPrompt(opts) {
|
|
33
42
|
// Pre-aborted signal: refuse to spawn at all.
|
|
34
43
|
if (opts.signal?.aborted === true) {
|
|
@@ -60,6 +69,7 @@ export async function runCliPrompt(opts) {
|
|
|
60
69
|
});
|
|
61
70
|
}
|
|
62
71
|
const args = buildArgs(opts);
|
|
72
|
+
assertPromptFitsCommandLine(opts.command, args);
|
|
63
73
|
const env = buildKimiCliEnvironment(opts);
|
|
64
74
|
if (opts.logPath !== undefined) {
|
|
65
75
|
await mkdir(path.dirname(opts.logPath), { recursive: true }).catch(() => {
|
|
@@ -82,8 +92,8 @@ export async function runCliPrompt(opts) {
|
|
|
82
92
|
}
|
|
83
93
|
catch (err) {
|
|
84
94
|
throw new RuntimeError("CLI_SPAWN_FAILED", `Failed to spawn kimi: ${formatError(err)}`, "cli-client.spawn", err instanceof Error
|
|
85
|
-
? { cause: err, details: { command: opts.command, args } }
|
|
86
|
-
: { details: { command: opts.command, args } });
|
|
95
|
+
? { cause: err, details: { command: opts.command, args, ...spawnErrorDetails(err) } }
|
|
96
|
+
: { details: { command: opts.command, args, ...spawnErrorDetails(err) } });
|
|
87
97
|
}
|
|
88
98
|
// Pre-attach an 'error' listener immediately. Bun (and Node) can emit
|
|
89
99
|
// 'error' asynchronously between spawn returning and our Promise body
|
|
@@ -550,8 +560,8 @@ export async function runCliPrompt(opts) {
|
|
|
550
560
|
child.on("error", (err) => {
|
|
551
561
|
appendLogLine({ event: "process_error", message: formatError(err) });
|
|
552
562
|
settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess error: ${formatError(err)}`, "cli-client.process", err instanceof Error
|
|
553
|
-
? { cause: err, details: errorDetails() }
|
|
554
|
-
: { details: errorDetails() }));
|
|
563
|
+
? { cause: err, details: { ...errorDetails(), ...spawnErrorDetails(err) } }
|
|
564
|
+
: { details: { ...errorDetails(), ...spawnErrorDetails(err) } }));
|
|
555
565
|
});
|
|
556
566
|
if (earlyErrors.length > 0) {
|
|
557
567
|
const captured = earlyErrors[0];
|
|
@@ -562,7 +572,7 @@ export async function runCliPrompt(opts) {
|
|
|
562
572
|
early: true,
|
|
563
573
|
additional_count: earlyErrors.length - 1,
|
|
564
574
|
});
|
|
565
|
-
settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess error: ${formatError(captured)}`, "cli-client.process", { cause: captured, details: errorDetails() }));
|
|
575
|
+
settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess error: ${formatError(captured)}`, "cli-client.process", { cause: captured, details: { ...errorDetails(), ...spawnErrorDetails(captured) } }));
|
|
566
576
|
});
|
|
567
577
|
}
|
|
568
578
|
child.on("close", async (exitCode, signal) => {
|
|
@@ -643,8 +653,8 @@ export async function runCliPrompt(opts) {
|
|
|
643
653
|
}
|
|
644
654
|
catch (err) {
|
|
645
655
|
settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess stream setup failed: ${formatError(err)}`, "cli-client.streams", err instanceof Error
|
|
646
|
-
? { cause: err, details: errorDetails() }
|
|
647
|
-
: { details: errorDetails() }));
|
|
656
|
+
? { cause: err, details: { ...errorDetails(), ...spawnErrorDetails(err) } }
|
|
657
|
+
: { details: { ...errorDetails(), ...spawnErrorDetails(err) } }));
|
|
648
658
|
}
|
|
649
659
|
});
|
|
650
660
|
}
|
package/dist/commands/cancel.js
CHANGED
|
@@ -36,6 +36,14 @@ export async function runCancel(argv, context) {
|
|
|
36
36
|
(context.mcpCompanionIdentity === undefined || context.mcpKimiIdentity === undefined)) {
|
|
37
37
|
throw new RuntimeError("MCP_CANCEL_REQUIRES_BINDING", `MCP job ${job.job_id} requires MCP job-control cancellation with recorded identities; use tasks/cancel or kimi_job_cancel.`, "mcp.cancel");
|
|
38
38
|
}
|
|
39
|
+
const recovery = store.getSwarmRecovery(job.job_id)?.record;
|
|
40
|
+
if (recovery) {
|
|
41
|
+
const identity = recovery.owner;
|
|
42
|
+
const verified = identity && identity.pid === job.pid ? await revalidateProcessIdentities([identity]) : undefined;
|
|
43
|
+
if (!verified?.complete || verified.groupChanged?.length || verified.identities.length !== 1) {
|
|
44
|
+
throw new RuntimeError("SWARM_RECOVERY_REQUIRED", `Recorded swarm ownership is no longer live or verifiable. Preview companion.sh recover-swarm ${job.job_id}; no process was signalled.`, "cancel.identity");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
39
47
|
if (job.pid === null && job.kimi_pid === null) {
|
|
40
48
|
throw new RuntimeError("CANCEL_NOT_SUPPORTED", `Job ${job.job_id} does not have a recorded process id to cancel.`, "cancel.runtime");
|
|
41
49
|
}
|
|
@@ -75,7 +83,10 @@ export async function runCancel(argv, context) {
|
|
|
75
83
|
}
|
|
76
84
|
await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity);
|
|
77
85
|
await assertMcpKimiIdentity(job, context.mcpKimiIdentity);
|
|
78
|
-
|
|
86
|
+
if (recovery)
|
|
87
|
+
await assertMcpCompanionIdentity(job, recovery.owner);
|
|
88
|
+
// The live swarm owner owns child teardown, including web phases after print exit.
|
|
89
|
+
signalJobProcesses(job, "SIGTERM", { companion: true, kimi: !recovery });
|
|
79
90
|
const cancelled = await waitForCancellation(paths, job.job_id);
|
|
80
91
|
if (cancelled) {
|
|
81
92
|
return `${JSON.stringify({
|
|
@@ -84,10 +95,10 @@ export async function runCancel(argv, context) {
|
|
|
84
95
|
message: "Cancellation completed.",
|
|
85
96
|
}, null, 2)}\n`;
|
|
86
97
|
}
|
|
87
|
-
const companionMatches = await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity, true);
|
|
98
|
+
const companionMatches = await assertMcpCompanionIdentity(job, recovery ? recovery.owner : context.mcpCompanionIdentity, true);
|
|
88
99
|
const kimiMatches = await assertMcpKimiIdentity(job, context.mcpKimiIdentity, true);
|
|
89
100
|
if (companionMatches || kimiMatches) {
|
|
90
|
-
signalJobProcesses(job, "SIGKILL", { companion: companionMatches, kimi: kimiMatches });
|
|
101
|
+
signalJobProcesses(job, "SIGKILL", { companion: companionMatches, kimi: !recovery && kimiMatches });
|
|
91
102
|
}
|
|
92
103
|
await withJobStore(paths, async (forcedStore) => {
|
|
93
104
|
const current = forcedStore.getJob(job.job_id);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { RECOVER_SWARM_SUPPORTED_FLAGS } from "../parsing.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, realpath, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createCliCancellationHandlers } from "../cli-cancellation.js";
|
|
6
|
+
import { RuntimeError } from "../errors.js";
|
|
7
|
+
import { captureWorktreePatch, resolveRepoIdentity } from "../git.js";
|
|
8
|
+
import { verifyHookInstalled } from "../hooks/install.js";
|
|
9
|
+
import { markJobFailed } from "../jobs.js";
|
|
10
|
+
import { JobStore } from "../job-store.js";
|
|
11
|
+
import { executionPlanFromPersisted, prepareKimiExecutionPlan } from "../kimi-engine.js";
|
|
12
|
+
import { resolveKimiHome } from "../kimi-home.js";
|
|
13
|
+
import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
|
|
14
|
+
import { probeProcessIdentity } from "../process-tree.js";
|
|
15
|
+
import { deactivateSwarmSession } from "../swarm-activation.js";
|
|
16
|
+
import { assertRecoveryOwnerGone, finishRecoveryRecord, recordRecoveryEvent, recoverLaunch, recoveryError } from "../swarm-recovery.js";
|
|
17
|
+
export async function runRecoverSwarm(argv, context) {
|
|
18
|
+
const positional = argv.filter((arg) => !arg.startsWith("-"));
|
|
19
|
+
if (positional.length !== 1 || argv.some((arg) => arg.startsWith("-") && arg !== RECOVER_SWARM_SUPPORTED_FLAGS) || argv.filter((arg) => arg === RECOVER_SWARM_SUPPORTED_FLAGS).length > 1) {
|
|
20
|
+
throw new RuntimeError("INVALID_ARGS", `Usage: companion.sh recover-swarm <job-id> [${RECOVER_SWARM_SUPPORTED_FLAGS}]. Defaults to preview.`, "swarm.recovery.args");
|
|
21
|
+
}
|
|
22
|
+
const jobId = positional[0];
|
|
23
|
+
const apply = argv.includes(RECOVER_SWARM_SUPPORTED_FLAGS);
|
|
24
|
+
const paths = resolvePluginPaths(context.env);
|
|
25
|
+
await ensurePluginPaths(paths);
|
|
26
|
+
const repo = await resolveRepoIdentity(context.cwd);
|
|
27
|
+
const store = new JobStore(paths);
|
|
28
|
+
let claim;
|
|
29
|
+
const handlers = createCliCancellationHandlers();
|
|
30
|
+
const signal = AbortSignal.any([handlers.signal, AbortSignal.timeout(30_000)]);
|
|
31
|
+
try {
|
|
32
|
+
const job = store.getJob(jobId);
|
|
33
|
+
const saved = store.getSwarmRecovery(jobId);
|
|
34
|
+
if (!job || job.repo_id !== repo.repoId || !saved || saved.record.schema !== 1 || saved.record.jobId !== jobId) {
|
|
35
|
+
throw recoveryError("No recovery record owned by this repository. Older runs require manual inspection.");
|
|
36
|
+
}
|
|
37
|
+
let record = saved.record;
|
|
38
|
+
const summary = () => ({ job_id: jobId, mode: apply ? "apply" : "preview", recovery_complete: record.complete,
|
|
39
|
+
session_id: record.session?.sessionId ?? null, session_cleanup_pending: record.sessionDirty,
|
|
40
|
+
launches: record.launches, worktree: record.workspaceRoot, patch_path: record.patchPath,
|
|
41
|
+
next_step: record.complete ? "No recovery is needed. Retained worktrees may be inspected and removed manually."
|
|
42
|
+
: `Inspect this record; use companion.sh recover-swarm ${jobId} --apply only after the original job has stopped.` });
|
|
43
|
+
const settleAbandonedJob = async () => {
|
|
44
|
+
if (job.status === "running")
|
|
45
|
+
await markJobFailed(store, paths, job, new RuntimeError("SWARM_OWNER_DISAPPEARED", "The original swarm companion stopped without reporting a result; resource recovery completed, not the task.", "swarm.recovery"), "Abandoned swarm resources recovered; task did not complete.", { phase: "failed" });
|
|
46
|
+
};
|
|
47
|
+
if (!apply)
|
|
48
|
+
return `${JSON.stringify(summary(), null, 2)}\n`;
|
|
49
|
+
if (record.complete) {
|
|
50
|
+
if (job.status === "running") {
|
|
51
|
+
await assertRecoveryOwnerGone(record);
|
|
52
|
+
await settleAbandonedJob();
|
|
53
|
+
}
|
|
54
|
+
return `${JSON.stringify(summary(), null, 2)}\n`;
|
|
55
|
+
}
|
|
56
|
+
await assertRecoveryOwnerGone(record);
|
|
57
|
+
if (saved.owner !== null) {
|
|
58
|
+
const owner = JSON.parse(saved.owner);
|
|
59
|
+
const live = await probeProcessIdentity(owner.pid);
|
|
60
|
+
if (!live.complete || live.identity?.startToken === owner.startToken)
|
|
61
|
+
throw recoveryError("Another recovery owner is alive or cannot be verified.");
|
|
62
|
+
}
|
|
63
|
+
const self = await probeProcessIdentity(process.pid);
|
|
64
|
+
if (!self.complete || !self.identity)
|
|
65
|
+
throw recoveryError("Cannot establish recovery ownership on this host.");
|
|
66
|
+
claim = JSON.stringify(self.identity);
|
|
67
|
+
if (!store.claimSwarmRecovery(jobId, saved.owner, claim)) {
|
|
68
|
+
claim = undefined;
|
|
69
|
+
throw recoveryError("Another recovery acquired this job.");
|
|
70
|
+
}
|
|
71
|
+
// Another owner may have completed and released the claim between our first read
|
|
72
|
+
// and the CAS (null -> owner -> null). Never overwrite that newer journal.
|
|
73
|
+
record = store.getSwarmRecovery(jobId).record;
|
|
74
|
+
if (record.complete) {
|
|
75
|
+
await settleAbandonedJob();
|
|
76
|
+
return `${JSON.stringify(summary(), null, 2)}\n`;
|
|
77
|
+
}
|
|
78
|
+
const expectedRoot = job.operation_kind === "swarm-write"
|
|
79
|
+
? path.join(paths.worktreesDir, job.repo_id, `swarm-write-${job.job_id}`) : null;
|
|
80
|
+
if (record.workspaceRoot !== expectedRoot || record.cwd !== (expectedRoot ?? job.cwd)
|
|
81
|
+
|| record.kimiHome !== resolveKimiHome(context.env, record.cwd)) {
|
|
82
|
+
throw recoveryError("Recovery workspace or Kimi home differs from the recorded job. Use its original home and repository; do not guess a session.");
|
|
83
|
+
}
|
|
84
|
+
if (expectedRoot) {
|
|
85
|
+
const st = await lstat(expectedRoot);
|
|
86
|
+
if (!st.isDirectory() || st.isSymbolicLink())
|
|
87
|
+
throw recoveryError("Recovery worktree must be the original non-symlink directory.");
|
|
88
|
+
const parent = await realpath(path.dirname(expectedRoot));
|
|
89
|
+
if (await realpath(expectedRoot) !== path.join(parent, path.basename(expectedRoot)))
|
|
90
|
+
throw recoveryError("Recovery worktree path is redirected.");
|
|
91
|
+
}
|
|
92
|
+
const persist = () => store.saveSwarmRecovery(jobId, record);
|
|
93
|
+
for (const launch of record.launches)
|
|
94
|
+
await recoverLaunch(launch, persist, signal);
|
|
95
|
+
if (record.sessionDirty) {
|
|
96
|
+
if (!record.session || !/^[\w-]+$/.test(record.session.sessionId) || !/^[\w-]+$/.test(record.session.workspaceId))
|
|
97
|
+
throw recoveryError("Recovery session identity is missing or invalid.");
|
|
98
|
+
const installed = await verifyHookInstalled(context.env);
|
|
99
|
+
if (!installed.installed)
|
|
100
|
+
throw recoveryError(`Managed hook verification failed: ${installed.reason ?? "unknown"}. Recovery does not install or relax hooks.`);
|
|
101
|
+
const plan = executionPlanFromPersisted(job);
|
|
102
|
+
const current = await prepareKimiExecutionPlan({ operationKind: plan.operationKind, cwd: record.cwd, env: context.env });
|
|
103
|
+
if (current.command !== plan.command || current.kimiVersion !== plan.kimiVersion || JSON.stringify(current.prefixArgs) !== JSON.stringify(plan.prefixArgs))
|
|
104
|
+
throw recoveryError("The exact recorded CLI is not selected. Restore that certified binary before session cleanup.");
|
|
105
|
+
signal.throwIfAborted();
|
|
106
|
+
await deactivateSwarmSession({ cwd: record.cwd, env: context.env, command: current.command,
|
|
107
|
+
prefixArgs: current.prefixArgs, executionPlan: current, commandLabel: expectedRoot ? "swarm-write" : "swarm",
|
|
108
|
+
trustedWorkspaceRoot: expectedRoot ?? undefined, ...record.session, signal,
|
|
109
|
+
onRecoveryEvent: (event) => recordRecoveryEvent(store, record, event) });
|
|
110
|
+
}
|
|
111
|
+
signal.throwIfAborted();
|
|
112
|
+
if (expectedRoot) {
|
|
113
|
+
// Capture without applying or deleting. A crash after the write leaves a recoverable artifact.
|
|
114
|
+
const patch = await captureWorktreePatch(expectedRoot, signal);
|
|
115
|
+
const patchPath = path.join(paths.artifactsDir, `swarm-recovered-${jobId}-${randomUUID()}.patch`);
|
|
116
|
+
await writeFile(patchPath, patch, { flag: "wx", mode: 0o600 });
|
|
117
|
+
record.patchPath = patchPath;
|
|
118
|
+
}
|
|
119
|
+
finishRecoveryRecord(store, record);
|
|
120
|
+
if (!record.complete)
|
|
121
|
+
throw recoveryError("Recovery remains incomplete; evidence and worktree were retained.");
|
|
122
|
+
await settleAbandonedJob();
|
|
123
|
+
return `${JSON.stringify(summary(), null, 2)}\n`;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
handlers.dispose();
|
|
127
|
+
try {
|
|
128
|
+
if (claim !== undefined)
|
|
129
|
+
store.claimSwarmRecovery(jobId, claim, null);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
store.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/swarm.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
|
|
730
|
+
try {
|
|
731
|
+
if (recovery)
|
|
732
|
+
finishRecoveryRecord(store, recovery);
|
|
733
|
+
if (recovery && !recovery.complete) {
|
|
734
|
+
writeMode?.onPatchCaptureFailure?.();
|
|
735
|
+
context.stderr.write(`[kimi-plugin-cc] recovery record retained: companion.sh recover-swarm ${job.job_id} (preview).\n`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
// A failed final journal commit cannot authorize removal of recovery evidence.
|
|
740
|
+
writeMode?.onPatchCaptureFailure?.();
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
store.close();
|
|
745
|
+
}
|
|
749
746
|
}
|
|
750
747
|
}
|
|
751
748
|
function shorten(text, max) {
|
package/dist/companion.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runRecoverSwarm } from "./commands/recover-swarm.js";
|
|
1
2
|
import { companionHelp } from "./help.js";
|
|
2
3
|
import { runModelInventory } from "./commands/models.js";
|
|
3
4
|
import { runCancel } from "./commands/cancel.js";
|
|
@@ -77,6 +78,9 @@ async function main(argv) {
|
|
|
77
78
|
case "replay":
|
|
78
79
|
context.stdout.write(await runReplay(rest, context));
|
|
79
80
|
return;
|
|
81
|
+
case "recover-swarm":
|
|
82
|
+
context.stdout.write(await runRecoverSwarm(rest, context));
|
|
83
|
+
return;
|
|
80
84
|
case "repair-sessions":
|
|
81
85
|
context.stdout.write(await runRepairSessions(rest, context));
|
|
82
86
|
return;
|
|
@@ -84,7 +88,7 @@ async function main(argv) {
|
|
|
84
88
|
await runWorker(rest, context);
|
|
85
89
|
return;
|
|
86
90
|
default:
|
|
87
|
-
throw new RuntimeError("INVALID_COMMAND", `Unknown or missing companion subcommand: ${command ?? "<none>"}. Expected one of setup, review, task, ask, status, result, cancel, replay, repair-sessions.`, "companion");
|
|
91
|
+
throw new RuntimeError("INVALID_COMMAND", `Unknown or missing companion subcommand: ${command ?? "<none>"}. Expected one of setup, review, task, ask, status, result, cancel, replay, repair-sessions, recover-swarm.`, "companion");
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
main(process.argv.slice(2)).catch((error) => {
|
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.";
|
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
|
}
|