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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +17 -9
- package/dist/cli-client.js +19 -9
- package/dist/commands/cancel.js +14 -3
- package/dist/commands/cli-helpers.js +5 -1
- package/dist/commands/recover-swarm.js +135 -0
- package/dist/commands/status.js +1 -1
- package/dist/commands/swarm.js +241 -87
- package/dist/companion.js +5 -1
- package/dist/git.js +5 -3
- package/dist/help.js +3 -2
- package/dist/hooks/config-safety.js +4 -3
- package/dist/job-store.js +13 -0
- package/dist/jobs.js +3 -0
- package/dist/kimi-engine.js +1 -0
- package/dist/kimi-errors.js +27 -21
- package/dist/parsing.js +1 -0
- package/dist/process-tree.js +48 -17
- package/dist/prompt-size.js +18 -0
- package/dist/swarm-activation.js +833 -0
- package/dist/swarm-recovery.js +134 -0
- package/dist/version.js +1 -1
- package/docs/migration.md +12 -2
- package/docs/registry-releases.md +13 -2
- package/docs/safety.md +20 -6
- package/docs/swarm-recovery.md +33 -0
- package/docs/windows-validation.md +39 -0
- package/package.json +7 -4
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/cli-client.js +19 -9
- package/plugins/kimi-codex/dist/commands/cancel.js +14 -3
- package/plugins/kimi-codex/dist/commands/cli-helpers.js +5 -1
- 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 +241 -87
- 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/hooks/config-safety.js +4 -3
- 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-engine.js +1 -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/process-tree.js +48 -17
- package/plugins/kimi-codex/dist/prompt-size.js +18 -0
- package/plugins/kimi-codex/dist/swarm-activation.js +833 -0
- package/plugins/kimi-codex/dist/swarm-recovery.js +134 -0
- 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,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,
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
},
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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) => {
|