kimi-plugin-cc 2.4.5 → 2.5.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 +22 -0
- package/README.md +103 -29
- package/dist/commands/setup.js +78 -51
- package/dist/dsh/companion.js +177 -0
- package/dist/dsh/index.js +169 -0
- package/dist/hooks/install-paths.js +2 -2
- package/dist/hooks/managed-block.js +86 -20
- package/dist/version.js +1 -1
- package/docs/migration.md +4 -2
- package/docs/models.md +5 -1
- package/docs/registry-releases.md +84 -20
- package/docs/safety.md +25 -10
- package/docs/upstream-2.4.1-certification.md +6 -0
- package/package.json +75 -8
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/commands/setup.js +78 -51
- package/plugins/kimi-codex/dist/dsh/companion.js +177 -0
- package/plugins/kimi-codex/dist/dsh/index.js +169 -0
- package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
- package/plugins/kimi-codex/dist/hooks/managed-block.js +86 -20
- package/plugins/kimi-codex/dist/version.js +1 -1
- package/plugins/kimi-dsh/LICENSE +201 -0
- package/plugins/kimi-dsh/README.md +83 -0
- package/plugins/kimi-dsh/cordis.patch.yml +45 -0
- package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
- package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
- package/plugins/kimi-dsh/dist/cli-client.js +876 -0
- package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
- package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
- package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
- package/plugins/kimi-dsh/dist/commands/models.js +140 -0
- package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
- package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
- package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
- package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
- package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
- package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
- package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
- package/plugins/kimi-dsh/dist/commands/result.js +66 -0
- package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
- package/plugins/kimi-dsh/dist/commands/review.js +220 -0
- package/plugins/kimi-dsh/dist/commands/setup.js +1074 -0
- package/plugins/kimi-dsh/dist/commands/status.js +27 -0
- package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
- package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
- package/plugins/kimi-dsh/dist/companion.js +97 -0
- package/plugins/kimi-dsh/dist/config.js +22 -0
- package/plugins/kimi-dsh/dist/dsh/companion.js +177 -0
- package/plugins/kimi-dsh/dist/dsh/index.js +169 -0
- package/plugins/kimi-dsh/dist/errors.js +40 -0
- package/plugins/kimi-dsh/dist/git.js +224 -0
- package/plugins/kimi-dsh/dist/help.js +43 -0
- package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
- package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
- package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
- package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
- package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
- package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
- package/plugins/kimi-dsh/dist/hooks/managed-block.js +685 -0
- package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
- package/plugins/kimi-dsh/dist/job-id.js +12 -0
- package/plugins/kimi-dsh/dist/job-store.js +659 -0
- package/plugins/kimi-dsh/dist/jobs.js +217 -0
- package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
- package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
- package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
- package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
- package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
- package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
- package/plugins/kimi-dsh/dist/logging.js +22 -0
- package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
- package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
- package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
- package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
- package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
- package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
- package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
- package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
- package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
- package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
- package/plugins/kimi-dsh/dist/model-command.js +32 -0
- package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
- package/plugins/kimi-dsh/dist/package.json +1 -0
- package/plugins/kimi-dsh/dist/parsing.js +620 -0
- package/plugins/kimi-dsh/dist/paths.js +27 -0
- package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
- package/plugins/kimi-dsh/dist/process-tree.js +479 -0
- package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
- package/plugins/kimi-dsh/dist/render.js +177 -0
- package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
- package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
- package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
- package/plugins/kimi-dsh/dist/session-title.js +293 -0
- package/plugins/kimi-dsh/dist/stream-json.js +346 -0
- package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
- package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
- package/plugins/kimi-dsh/dist/types.js +1 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
- package/plugins/kimi-dsh/dist/version.js +7 -0
- package/plugins/kimi-dsh/package.json +78 -0
- package/plugins/kimi-dsh/scripts/companion.sh +37 -0
- package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { RuntimeError } from "../errors.js";
|
|
2
|
+
const passthroughOutput = "passthrough";
|
|
3
|
+
const parsedOutput = "parsed";
|
|
4
|
+
const DEFAULT_ESCALATION_MS = 1_500;
|
|
5
|
+
/**
|
|
6
|
+
* Build cancellation config from the display name used verbatim in messages
|
|
7
|
+
* and the uppercase stem used in error codes.
|
|
8
|
+
*/
|
|
9
|
+
function makeCancellationConfig(prefix) {
|
|
10
|
+
const { displayName, errorCodeStem } = prefix;
|
|
11
|
+
return {
|
|
12
|
+
escalationMs: DEFAULT_ESCALATION_MS,
|
|
13
|
+
errorCodes: {
|
|
14
|
+
cancelledDuringStart: `${errorCodeStem}_CANCELLED_DURING_START`,
|
|
15
|
+
cancelled: `${errorCodeStem}_CANCELLED`,
|
|
16
|
+
},
|
|
17
|
+
cancelMessages: {
|
|
18
|
+
duringStart: `${displayName} cancelled during startup.`,
|
|
19
|
+
afterPrompt: `${displayName} cancelled by user request after prompt completion.`,
|
|
20
|
+
afterArtifact: `${displayName} cancelled by user request after artifact write.`,
|
|
21
|
+
default: `${displayName} cancelled by user request.`,
|
|
22
|
+
},
|
|
23
|
+
cancelledSummary: `${displayName} cancelled by user request.`,
|
|
24
|
+
failedSummary: `${displayName} failed.`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Single source of truth for per-command behavior. Only commands using the
|
|
29
|
+
* managed-cancellation flow participate; `setup` and `task` do not.
|
|
30
|
+
*
|
|
31
|
+
* Display-name casing is part of the user-visible contract: `Ask` and `Rescue`
|
|
32
|
+
* are title-cased, while `review`, `challenge`, and `review_gate` are lowercase.
|
|
33
|
+
*/
|
|
34
|
+
export const COMMAND_REGISTRY = {
|
|
35
|
+
ask: {
|
|
36
|
+
outputMode: passthroughOutput,
|
|
37
|
+
cancellation: makeCancellationConfig({ displayName: "Ask", errorCodeStem: "ASK" }),
|
|
38
|
+
},
|
|
39
|
+
review: {
|
|
40
|
+
outputMode: passthroughOutput,
|
|
41
|
+
cancellation: makeCancellationConfig({ displayName: "review", errorCodeStem: "REVIEW" }),
|
|
42
|
+
},
|
|
43
|
+
challenge: {
|
|
44
|
+
outputMode: passthroughOutput,
|
|
45
|
+
cancellation: makeCancellationConfig({ displayName: "challenge", errorCodeStem: "CHALLENGE" }),
|
|
46
|
+
},
|
|
47
|
+
rescue: {
|
|
48
|
+
outputMode: passthroughOutput,
|
|
49
|
+
cancellation: makeCancellationConfig({ displayName: "Rescue", errorCodeStem: "RESCUE" }),
|
|
50
|
+
},
|
|
51
|
+
review_gate: {
|
|
52
|
+
outputMode: parsedOutput,
|
|
53
|
+
cancellation: makeCancellationConfig({ displayName: "review_gate", errorCodeStem: "REVIEW_GATE" }),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Convenience accessor for managed commands (review/challenge/rescue/
|
|
58
|
+
* review_gate/ask). Identical to direct lookup, with a defensive
|
|
59
|
+
* runtime guard: TypeScript narrows `commandType` to `ManagedCommandType`,
|
|
60
|
+
* but a stale SQLite row, corrupted job artifact, or future command
|
|
61
|
+
* type added without a registry entry could still reach here at
|
|
62
|
+
* runtime. A bare `COMMAND_REGISTRY[commandType]` would return
|
|
63
|
+
* `undefined` and surface as an unclassified `TypeError` at the
|
|
64
|
+
* destructure call site. Throwing a classified `RuntimeError` here
|
|
65
|
+
* keeps the failure path within the command-layer error model so
|
|
66
|
+
* /kimi:status and /kimi:result render it correctly.
|
|
67
|
+
*/
|
|
68
|
+
export function getManagedCommandConfig(commandType) {
|
|
69
|
+
const config = COMMAND_REGISTRY[commandType];
|
|
70
|
+
if (!config) {
|
|
71
|
+
throw new RuntimeError("UNKNOWN_COMMAND_TYPE", `No registry entry for command type "${String(commandType)}". This indicates a stale job record or an unregistered command.`, "commands.registry");
|
|
72
|
+
}
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { REPAIR_SESSIONS_SUPPORTED_FLAGS } from "../parsing.js";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open } from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { RuntimeError } from "../errors.js";
|
|
7
|
+
import { resolveRepoIdentity } from "../git.js";
|
|
8
|
+
import { digestPrompt } from "../jobs.js";
|
|
9
|
+
import { resolvePluginPaths } from "../paths.js";
|
|
10
|
+
import { syncKimiSessionTitle } from "../session-title.js";
|
|
11
|
+
// Repair is deliberately finite and local. Stream logs may be very large, but
|
|
12
|
+
// the spawn record follows the invocation header: read only a bounded prefix.
|
|
13
|
+
const MAX_JOBS = 1000;
|
|
14
|
+
const MAX_SPAWN_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
export async function runRepairSessions(argv, context) {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const arg of argv) {
|
|
18
|
+
if ((arg !== "--apply" && arg !== "--all") || seen.has(arg)) {
|
|
19
|
+
throw new RuntimeError("INVALID_ARGS", `Usage: repair-sessions. Supported flags: ${REPAIR_SESSIONS_SUPPORTED_FLAGS}.`, "repair-sessions.args");
|
|
20
|
+
}
|
|
21
|
+
seen.add(arg);
|
|
22
|
+
}
|
|
23
|
+
const apply = seen.has("--apply");
|
|
24
|
+
const all = seen.has("--all");
|
|
25
|
+
const paths = resolvePluginPaths(context.env);
|
|
26
|
+
const repoId = all ? undefined : (await resolveRepoIdentity(context.cwd)).repoId;
|
|
27
|
+
const jobs = await readRepairJobs(paths.stateDbPath, repoId);
|
|
28
|
+
const sessions = new Set();
|
|
29
|
+
const results = [];
|
|
30
|
+
for (const job of jobs) {
|
|
31
|
+
if (sessions.has(job.kimi_session_id))
|
|
32
|
+
continue;
|
|
33
|
+
sessions.add(job.kimi_session_id);
|
|
34
|
+
const promptText = await readVerifiedPrompt(job, paths.logsDir, paths.worktreesDir);
|
|
35
|
+
const status = promptText === undefined
|
|
36
|
+
? "unavailable-verified-prompt"
|
|
37
|
+
: await syncKimiSessionTitle({
|
|
38
|
+
env: context.env,
|
|
39
|
+
cwd: job.cwd,
|
|
40
|
+
sessionId: job.kimi_session_id,
|
|
41
|
+
title: "",
|
|
42
|
+
promptText,
|
|
43
|
+
dryRun: !apply,
|
|
44
|
+
preserveTitle: true,
|
|
45
|
+
requireNativeV2: true,
|
|
46
|
+
stderr: context.stderr,
|
|
47
|
+
});
|
|
48
|
+
results.push({ job_id: job.job_id, session_id: job.kimi_session_id, status });
|
|
49
|
+
}
|
|
50
|
+
const counts = {};
|
|
51
|
+
for (const { status } of results)
|
|
52
|
+
counts[status] = (counts[status] ?? 0) + 1;
|
|
53
|
+
return `${JSON.stringify({ mode: apply ? "apply" : "dry-run", scope: all ? "host" : "repository", sessions: results.length, counts, results }, null, 2)}\n`;
|
|
54
|
+
}
|
|
55
|
+
async function readRepairJobs(filename, repoId) {
|
|
56
|
+
try {
|
|
57
|
+
const stats = await lstat(filename);
|
|
58
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
59
|
+
throw new RuntimeError("SESSION_REPAIR_UNSAFE_STORE", "Repair requires a regular, non-symlink job database.", "repair-sessions.store");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === "ENOENT")
|
|
64
|
+
return [];
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const require = createRequire(import.meta.url);
|
|
68
|
+
// Bind constructors before `new`: compiler emission must not turn the
|
|
69
|
+
// module-loader call into the constructor (new require(...).Database).
|
|
70
|
+
const db = (() => {
|
|
71
|
+
if (typeof Bun !== "undefined") {
|
|
72
|
+
const { Database } = require("bun:sqlite");
|
|
73
|
+
return new Database(filename, { readonly: true });
|
|
74
|
+
}
|
|
75
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
76
|
+
return new DatabaseSync(filename, { readOnly: true });
|
|
77
|
+
})();
|
|
78
|
+
try {
|
|
79
|
+
// Never construct JobStore here: it performs migrations and stale-row
|
|
80
|
+
// reconciliation. Both preview and apply keep plugin job data read-only.
|
|
81
|
+
const columns = db.prepare("PRAGMA table_info(jobs)").all();
|
|
82
|
+
const names = new Set(columns.map((column) => column.name));
|
|
83
|
+
const required = ["job_id", "repo_id", "command_type", "operation_kind", "status", "kimi_session_id", "cwd", "kimi_version", "intended_engine", "observed_engine", "stream_log_path", "prompt_digest", "updated_at"];
|
|
84
|
+
if (required.some((name) => !names.has(name)))
|
|
85
|
+
return [];
|
|
86
|
+
const rows = db.prepare(`
|
|
87
|
+
SELECT j.job_id, j.repo_id, j.operation_kind, j.kimi_session_id, j.cwd, j.kimi_version, j.stream_log_path, j.prompt_digest
|
|
88
|
+
FROM jobs j
|
|
89
|
+
WHERE j.status = 'completed' AND j.intended_engine = 'native-v2' AND j.observed_engine = 'native-v2'
|
|
90
|
+
AND j.command_type != 'review_gate'
|
|
91
|
+
AND j.operation_kind IN ('ask', 'review', 'challenge', 'rescue', 'pursue', 'swarm', 'swarm-write')
|
|
92
|
+
AND j.kimi_session_id IS NOT NULL AND length(trim(j.kimi_session_id)) > 0
|
|
93
|
+
AND j.kimi_version IS NOT NULL
|
|
94
|
+
${repoId === undefined ? "" : "AND j.repo_id = ?"}
|
|
95
|
+
AND NOT EXISTS (SELECT 1 FROM jobs active WHERE active.kimi_session_id = j.kimi_session_id AND active.status = 'running')
|
|
96
|
+
ORDER BY j.updated_at DESC, j.job_id DESC LIMIT ${MAX_JOBS + 1}
|
|
97
|
+
`).all(...(repoId === undefined ? [] : [repoId]));
|
|
98
|
+
if (rows.length > MAX_JOBS) {
|
|
99
|
+
throw new RuntimeError("SESSION_REPAIR_LIMIT", `More than ${MAX_JOBS} eligible jobs; narrow the repair to one repository.`, "repair-sessions.store");
|
|
100
|
+
}
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
db.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function readVerifiedPrompt(job, logsDir, worktreesDir) {
|
|
108
|
+
// All plugin stream logs are direct children. Refuse nested/escaped paths,
|
|
109
|
+
// symlinks and special files; opening nonblocking avoids FIFO hangs.
|
|
110
|
+
if (!path.isAbsolute(job.stream_log_path) || path.dirname(job.stream_log_path) !== logsDir)
|
|
111
|
+
return undefined;
|
|
112
|
+
try {
|
|
113
|
+
const root = await lstat(logsDir);
|
|
114
|
+
if (!root.isDirectory() || root.isSymbolicLink())
|
|
115
|
+
return undefined;
|
|
116
|
+
const file = await open(job.stream_log_path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
117
|
+
try {
|
|
118
|
+
const stats = await file.stat();
|
|
119
|
+
if (!stats.isFile())
|
|
120
|
+
return undefined;
|
|
121
|
+
const buffer = Buffer.alloc(MAX_SPAWN_BYTES + 1);
|
|
122
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
123
|
+
const prefix = buffer.subarray(0, bytesRead).toString("utf8");
|
|
124
|
+
const lines = prefix.split("\n");
|
|
125
|
+
// A prefix cut mid-record must never be treated as a complete JSON line.
|
|
126
|
+
if (bytesRead > MAX_SPAWN_BYTES)
|
|
127
|
+
lines.pop();
|
|
128
|
+
for (const line of lines.slice(0, 32)) {
|
|
129
|
+
if (!line.trim())
|
|
130
|
+
continue;
|
|
131
|
+
const record = JSON.parse(line);
|
|
132
|
+
if (record.event !== "spawn") {
|
|
133
|
+
if (record.direction === "meta")
|
|
134
|
+
continue;
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const expectedCwd = job.operation_kind === "swarm-write"
|
|
138
|
+
? path.join(worktreesDir, job.repo_id, `swarm-write-${job.job_id}`)
|
|
139
|
+
: job.cwd;
|
|
140
|
+
if (record.intended_engine !== "native-v2" || record.kimi_version !== job.kimi_version ||
|
|
141
|
+
record.operation_kind !== job.operation_kind || record.cwd !== expectedCwd)
|
|
142
|
+
return undefined;
|
|
143
|
+
if (!Array.isArray(record.args) || record.args.length < 2)
|
|
144
|
+
return undefined;
|
|
145
|
+
const args = record.args;
|
|
146
|
+
const prompt = args[args.length - 1];
|
|
147
|
+
if (args[args.length - 2] !== "-p" || typeof prompt !== "string" || !prompt.trim())
|
|
148
|
+
return undefined;
|
|
149
|
+
return digestPrompt(prompt) === job.prompt_digest ? prompt : undefined;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await file.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { REPLAY_SUPPORTED_FLAGS } from "../parsing.js";
|
|
2
|
+
import { access, stat, readFile } from "node:fs/promises";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { RuntimeError } from "../errors.js";
|
|
5
|
+
import { resolveRepoIdentity } from "../git.js";
|
|
6
|
+
import { withJobStore } from "../job-store.js";
|
|
7
|
+
import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
|
|
8
|
+
import { renderManagedJobOutput } from "../render.js";
|
|
9
|
+
import { sweepStaleJobs } from "../jobs.js";
|
|
10
|
+
const MAX_REPLAY_LOG_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
export async function runReplay(argv, context) {
|
|
12
|
+
const [jobId, ...rest] = argv;
|
|
13
|
+
if (!jobId || jobId.startsWith("-") || rest.length > 0) {
|
|
14
|
+
throw new RuntimeError("INVALID_ARGS", `replay expects exactly one job id: replay <job-id>. Supported flags: ${REPLAY_SUPPORTED_FLAGS}.`, "replay.parse");
|
|
15
|
+
}
|
|
16
|
+
const paths = resolvePluginPaths(context.env);
|
|
17
|
+
await ensurePluginPaths(paths);
|
|
18
|
+
const repoIdentity = await resolveRepoIdentity(context.cwd);
|
|
19
|
+
return withJobStore(paths, async (store) => {
|
|
20
|
+
await sweepStaleJobs(store, paths);
|
|
21
|
+
const job = store.getJob(jobId);
|
|
22
|
+
if (!job || job.repo_id !== repoIdentity.repoId) {
|
|
23
|
+
throw new RuntimeError("JOB_NOT_FOUND", `No job matched ${jobId} for replay.`, "replay.lookup");
|
|
24
|
+
}
|
|
25
|
+
const replayed = await replayJob(job);
|
|
26
|
+
return `${replayed.rendered}${replayed.rendered.endsWith("\n") ? "" : "\n"}`;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function replayJob(job) {
|
|
30
|
+
if (!job.stream_log_path) {
|
|
31
|
+
throw new RuntimeError("REPLAY_LOG_MISSING", `Job ${job.job_id} does not have a stored stream log path to replay.`, "replay.lookup");
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
await access(job.stream_log_path, fsConstants.R_OK);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new RuntimeError("REPLAY_LOG_MISSING", `Job ${job.job_id} cannot be replayed because ${job.stream_log_path} is missing.`, "replay.lookup");
|
|
38
|
+
}
|
|
39
|
+
const stats = await stat(job.stream_log_path);
|
|
40
|
+
if (stats.size > MAX_REPLAY_LOG_BYTES) {
|
|
41
|
+
throw new RuntimeError("REPLAY_LOG_TOO_LARGE", `Job ${job.job_id} stream log is ${stats.size} bytes, which exceeds the ${MAX_REPLAY_LOG_BYTES}-byte replay ceiling.`, "replay.lookup");
|
|
42
|
+
}
|
|
43
|
+
const finalText = await replayFinalText(job.stream_log_path);
|
|
44
|
+
const rendered = renderManagedJobOutput(job, finalText);
|
|
45
|
+
return {
|
|
46
|
+
rendered: rendered.rendered,
|
|
47
|
+
output: rendered.output,
|
|
48
|
+
summary: rendered.summary,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function replayFinalText(logPath) {
|
|
52
|
+
const contents = await readFile(logPath, "utf8");
|
|
53
|
+
const rawLines = contents.split("\n");
|
|
54
|
+
let sawSpawn = false;
|
|
55
|
+
let sawExit = false;
|
|
56
|
+
let sawV1Event = false;
|
|
57
|
+
let sawV04Direction = false;
|
|
58
|
+
let assistantContent = "";
|
|
59
|
+
for (let lineNumber = 0; lineNumber < rawLines.length; lineNumber += 1) {
|
|
60
|
+
const line = rawLines[lineNumber].trim();
|
|
61
|
+
if (!line)
|
|
62
|
+
continue;
|
|
63
|
+
let entry;
|
|
64
|
+
try {
|
|
65
|
+
entry = JSON.parse(line);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
// A truncated trailing line is the expected shape when a worker
|
|
69
|
+
// was SIGKILL'd mid-write. Drop it silently so replay still works
|
|
70
|
+
// on the consistent prefix.
|
|
71
|
+
if (lineNumber === rawLines.length - 1)
|
|
72
|
+
continue;
|
|
73
|
+
throw new RuntimeError("REPLAY_LOG_INVALID", `Stream log ${logPath}:${lineNumber + 1} is malformed JSON: ${error.message}`, "replay.log", { cause: error });
|
|
74
|
+
}
|
|
75
|
+
if (typeof entry.event === "string")
|
|
76
|
+
sawV1Event = true;
|
|
77
|
+
if (Object.prototype.hasOwnProperty.call(entry, "direction"))
|
|
78
|
+
sawV04Direction = true;
|
|
79
|
+
const event = typeof entry.event === "string" ? entry.event : "";
|
|
80
|
+
switch (event) {
|
|
81
|
+
case "spawn":
|
|
82
|
+
sawSpawn = true;
|
|
83
|
+
break;
|
|
84
|
+
case "record": {
|
|
85
|
+
const record = entry.record;
|
|
86
|
+
if (typeof record !== "object" || record === null || Array.isArray(record)) {
|
|
87
|
+
throw new RuntimeError("REPLAY_LOG_INVALID", `Stream log ${logPath}:${lineNumber + 1} record entry is not an object.`, "replay.log");
|
|
88
|
+
}
|
|
89
|
+
const role = record.role;
|
|
90
|
+
const content = record.content;
|
|
91
|
+
if (role === "assistant" && typeof content === "string") {
|
|
92
|
+
assistantContent += content;
|
|
93
|
+
}
|
|
94
|
+
// Non-string content (null/undefined/object) silently skipped —
|
|
95
|
+
// these are diagnostic-only records we don't render.
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case "exit":
|
|
99
|
+
sawExit = true;
|
|
100
|
+
break;
|
|
101
|
+
case "process_error": {
|
|
102
|
+
const detail = typeof entry.message === "string" ? entry.message : "<unknown>";
|
|
103
|
+
throw new RuntimeError("REPLAY_LOG_PROCESS_ERROR", `Stream log ${logPath} captured a subprocess error and cannot be replayed: ${detail}`, "replay.log", { details: { logPath, line: lineNumber + 1 } });
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
// Unknown / malformed-line / future events are diagnostics — skip.
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!sawSpawn) {
|
|
111
|
+
// Differentiate "this is a v0.4 wire log" from "this is a v1.0 log
|
|
112
|
+
// that died before reaching the spawn event". v0.4 logs had a
|
|
113
|
+
// `direction` field on every entry (out/in/meta/stderr); v1.0 logs
|
|
114
|
+
// use `event` instead.
|
|
115
|
+
if (sawV04Direction && !sawV1Event) {
|
|
116
|
+
throw new RuntimeError("REPLAY_LOG_UNREADABLE", `Stream log ${logPath} is a v0.4 wire log (no \`event\` key). v0.4 wire logs are not supported by the v1.0 replay command.`, "replay.log", { details: { logPath } });
|
|
117
|
+
}
|
|
118
|
+
throw new RuntimeError("REPLAY_LOG_NO_SPAWN", `Stream log ${logPath} contains no spawn event — the worker likely failed before launching kimi (e.g., synchronous spawn failure, early cancellation). No assistant content to replay.`, "replay.log", { details: { logPath } });
|
|
119
|
+
}
|
|
120
|
+
if (!sawExit && assistantContent.length === 0) {
|
|
121
|
+
throw new RuntimeError("REPLAY_LOG_INVALID", `Stream log ${logPath} did not capture any assistant content before truncation.`, "replay.log", { details: { logPath } });
|
|
122
|
+
}
|
|
123
|
+
return assistantContent;
|
|
124
|
+
}
|