kimi-plugin-cc 2.4.4 → 2.5.1
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 +21 -0
- package/README.md +50 -34
- package/dist/commands/swarm.js +1 -1
- package/dist/dsh/companion.js +174 -0
- package/dist/dsh/index.js +156 -0
- package/dist/hooks/config-safety.js +3 -2
- package/dist/hooks/install-paths.js +2 -2
- package/dist/kimi-engine.js +1 -0
- package/dist/swarm-activation.js +1 -1
- package/dist/version.js +1 -1
- package/docs/migration.md +4 -4
- package/docs/models.md +5 -1
- package/docs/registry-releases.md +20 -17
- package/docs/safety.md +28 -15
- package/docs/swarm-recovery.md +1 -1
- package/docs/upstream-2.4.1-certification.md +54 -0
- package/package.json +41 -6
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/commands/swarm.js +1 -1
- package/plugins/kimi-codex/dist/dsh/companion.js +174 -0
- package/plugins/kimi-codex/dist/dsh/index.js +156 -0
- package/plugins/kimi-codex/dist/hooks/config-safety.js +3 -2
- package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
- package/plugins/kimi-codex/dist/kimi-engine.js +1 -0
- package/plugins/kimi-codex/dist/swarm-activation.js +1 -1
- package/plugins/kimi-codex/dist/version.js +1 -1
- package/plugins/kimi-dsh/LICENSE +201 -0
- package/plugins/kimi-dsh/README.md +80 -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 +1047 -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 +174 -0
- package/plugins/kimi-dsh/dist/dsh/index.js +156 -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 +619 -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 +48 -0
- package/plugins/kimi-dsh/scripts/companion.sh +37 -0
- package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { RuntimeError } from "../errors.js";
|
|
2
|
+
import { ResponseTimeoutError } from "../cli-client.js";
|
|
3
|
+
import { observedExecutionFields } from "../kimi-engine.js";
|
|
4
|
+
import { resolveKimiHome } from "../kimi-home.js";
|
|
5
|
+
import { recoverSessionIdFromStore } from "../session-recovery.js";
|
|
6
|
+
import { formatBudgetDuration } from "../render.js";
|
|
7
|
+
/**
|
|
8
|
+
* Reassemble prose final text from cli-client records.
|
|
9
|
+
*
|
|
10
|
+
* The v0.4 wire client returned a single `finalText` field as the
|
|
11
|
+
* canonical assistant prose. Stream-json instead emits a sequence of
|
|
12
|
+
* records, three of which can carry assistant prose:
|
|
13
|
+
*
|
|
14
|
+
* - `{role:"assistant", content:"..."}` — text token
|
|
15
|
+
* - `{role:"assistant", tool_calls:[...]}` — no text
|
|
16
|
+
* - `{role:"tool", tool_call_id:"...", content}` — tool output, ignore
|
|
17
|
+
*
|
|
18
|
+
* We concatenate every assistant `content` field in arrival order. The
|
|
19
|
+
* order is preserved by stream-json's NDJSON contract — kimi-code emits
|
|
20
|
+
* one record per line, parser preserves emission order, runtime
|
|
21
|
+
* pushes to `records[]` in the same order.
|
|
22
|
+
*
|
|
23
|
+
* Two correctness notes:
|
|
24
|
+
*
|
|
25
|
+
* 1. Empty assistant records (assistant tool_calls without prose)
|
|
26
|
+
* contribute zero characters but still appear in `records`. We
|
|
27
|
+
* skip them without inserting a separator, otherwise back-to-back
|
|
28
|
+
* tool calls would inject blank lines.
|
|
29
|
+
*
|
|
30
|
+
* 2. We do NOT trim individual content fragments. kimi-code emits
|
|
31
|
+
* tokens that may include leading/trailing whitespace as part of
|
|
32
|
+
* the model's intended formatting. Only the final concatenated
|
|
33
|
+
* string is whitespace-managed by the caller's render layer.
|
|
34
|
+
*/
|
|
35
|
+
export function reassembleProseFromRecords(records) {
|
|
36
|
+
let out = "";
|
|
37
|
+
for (const record of records) {
|
|
38
|
+
if (record.role !== "assistant")
|
|
39
|
+
continue;
|
|
40
|
+
if (typeof record.content !== "string")
|
|
41
|
+
continue;
|
|
42
|
+
out += record.content;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Warn when kimi finishes without announcing a session ID. The job keeps
|
|
48
|
+
* `kimi_session_id` NULL, so resume is unavailable.
|
|
49
|
+
*
|
|
50
|
+
* This warning belongs on human stderr; load-bearing state remains in SQLite.
|
|
51
|
+
* Review-gate intentionally skips it because a Stop hook has no resume
|
|
52
|
+
* semantics and should not warn on every assistant turn.
|
|
53
|
+
*/
|
|
54
|
+
export function warnIfSessionIdMissing(result, commandLabel, jobId, stderr) {
|
|
55
|
+
if (result.sessionId !== undefined && result.sessionId.length > 0) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
stderr.write(`[${commandLabel}] kimi did not announce a session id for job ${jobId}. ` +
|
|
59
|
+
`Resume will not work for this job. Replay from the stored stream log is unaffected. ` +
|
|
60
|
+
`If this happens repeatedly, check kimi-code's session.resume_hint record (and the legacy stderr resume announcement) and report upstream.\n`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Translate a cli-client `CliClientResult` into the kind of error
|
|
64
|
+
* surface ask/review/rescue commands have always thrown when the
|
|
65
|
+
* underlying transport reported a failure.
|
|
66
|
+
*
|
|
67
|
+
* Order matters: a non-zero exit code may carry no records (process
|
|
68
|
+
* died early) or may carry partial records (model started but kimi
|
|
69
|
+
* crashed). Either way, we surface the exit-code failure first because
|
|
70
|
+
* trying to render a half-stream as `finalText` produces confusing
|
|
71
|
+
* downstream errors.
|
|
72
|
+
*
|
|
73
|
+
* `stage` is used as both the RuntimeError stage and a prefix-free
|
|
74
|
+
* label for code namespacing — callers should pass values like
|
|
75
|
+
* "ask.runtime" / "review.runtime" / "review_gate.runtime".
|
|
76
|
+
*/
|
|
77
|
+
export function assertCliResultSuccess(result, stage) {
|
|
78
|
+
if (result.aborted) {
|
|
79
|
+
// Aborted-by-our-signal is a cancellation, not a failure. The
|
|
80
|
+
// caller's outer handler checks `handlers.cancelling` and wraps
|
|
81
|
+
// into the canonical *_CANCELLED RuntimeError; we just need to
|
|
82
|
+
// bail without claiming the records are a complete response.
|
|
83
|
+
throw new RuntimeError("CLI_ABORTED", "kimi subprocess was cancelled before completing the response", stage, {
|
|
84
|
+
details: {
|
|
85
|
+
exit_code: result.exitCode,
|
|
86
|
+
signal: result.signal,
|
|
87
|
+
record_count: result.records.length,
|
|
88
|
+
stderr_tail_len: result.stderrTail.length,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (result.exitCode !== 0) {
|
|
93
|
+
const tail = result.cliError?.message ?? result.stderrTail.trim();
|
|
94
|
+
throw new RuntimeError("CLI_NONZERO_EXIT", [
|
|
95
|
+
`kimi subprocess exited with code ${result.exitCode}`,
|
|
96
|
+
result.signal ? ` (signal ${result.signal})` : "",
|
|
97
|
+
tail.length > 0 ? `:\n${tail}` : ".",
|
|
98
|
+
].join(""), stage, {
|
|
99
|
+
details: {
|
|
100
|
+
exit_code: result.exitCode,
|
|
101
|
+
signal: result.signal,
|
|
102
|
+
record_count: result.records.length,
|
|
103
|
+
malformed_count: result.malformed.length,
|
|
104
|
+
...(result.cliError !== undefined && { cli_error: result.cliError }),
|
|
105
|
+
stderr_tail_len: result.stderrTail.length,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Timeout-only capture. Cancellation takes precedence, including while the
|
|
111
|
+
* asynchronous store scan is in flight. No title sync on failure. */
|
|
112
|
+
export async function captureTimeoutPartialOutput(classified, store, job, env, cancellationSignal) {
|
|
113
|
+
if (cancellationSignal?.aborted)
|
|
114
|
+
return undefined;
|
|
115
|
+
const timeout = classified.cause;
|
|
116
|
+
if (!(timeout instanceof ResponseTimeoutError) || !timeout.partialResult)
|
|
117
|
+
return undefined;
|
|
118
|
+
const result = timeout.partialResult;
|
|
119
|
+
let sessionId = result.sessionId;
|
|
120
|
+
let source = sessionId
|
|
121
|
+
? sessionId === job.kimi_session_id
|
|
122
|
+
? job.kimi_session_id_source
|
|
123
|
+
: "announced"
|
|
124
|
+
: null;
|
|
125
|
+
const details = classified instanceof RuntimeError ? classified.details : timeout.details;
|
|
126
|
+
details.kimi_session_id_source = job.kimi_session_id_source;
|
|
127
|
+
// The persisted plan is plugin-owned. Recovery never runs on cancellation,
|
|
128
|
+
// v1, successful turns, teardown rejection, or an already-known resume id.
|
|
129
|
+
if (!sessionId && !job.kimi_session_id && job.intended_engine === "native-v2") {
|
|
130
|
+
// Without the system.version marker the row has no proven engine, and
|
|
131
|
+
// resume would refuse the id as lineage-unknown; do not advertise one.
|
|
132
|
+
if (result.observedEngine === null) {
|
|
133
|
+
details.session_recovery = "engine_unobserved";
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const recovered = await recoverSessionIdFromStore({
|
|
137
|
+
kimiHome: resolveKimiHome(env, job.cwd), cwd: job.cwd,
|
|
138
|
+
spawnedAt: result.spawnedAt, settledAt: result.settledAt, promptDigest: job.prompt_digest,
|
|
139
|
+
store, jobId: job.job_id,
|
|
140
|
+
});
|
|
141
|
+
sessionId = recovered.sessionId;
|
|
142
|
+
if (sessionId)
|
|
143
|
+
source = "recovered_session_dir";
|
|
144
|
+
else
|
|
145
|
+
details.session_recovery = recovered.reason;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (cancellationSignal?.aborted)
|
|
149
|
+
return undefined;
|
|
150
|
+
// Best-effort metadata only. The row is still `running`, and a session id
|
|
151
|
+
// written here can collide with `jobs_running_session_idx` when a resume of
|
|
152
|
+
// the same session was started while this job ran. A store error must never
|
|
153
|
+
// prevent markJobFailed from running — the partial artifact matters more
|
|
154
|
+
// than the metadata, so swallow and fall through.
|
|
155
|
+
try {
|
|
156
|
+
store.updateRunningJob(job.job_id, observedExecutionFields(result));
|
|
157
|
+
if (sessionId) {
|
|
158
|
+
const updated = source === "recovered_session_dir"
|
|
159
|
+
? store.claimRecoveredSessionId(job.job_id, sessionId)
|
|
160
|
+
: store.updateRunningJob(job.job_id, { kimi_session_id: sessionId, kimi_session_id_source: source });
|
|
161
|
+
if (updated?.kimi_session_id === sessionId && updated.kimi_session_id_source === source) {
|
|
162
|
+
details.kimi_session_id = sessionId;
|
|
163
|
+
details.kimi_session_id_source = source;
|
|
164
|
+
}
|
|
165
|
+
else
|
|
166
|
+
details[source === "recovered_session_dir" ? "session_recovery" : "session_id_persistence"] = "persistence_failed";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// `session_recovery` describes only the recovery branch; an announced id or
|
|
171
|
+
// the provenance update failing is a different fact.
|
|
172
|
+
details[source === "recovered_session_dir" ? "session_recovery" : "session_id_persistence"] = "persistence_failed";
|
|
173
|
+
// Diagnostics stay in the stream log; the failure transition proceeds.
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
text: reassembleProseFromRecords(result.records),
|
|
177
|
+
toolCalls: result.records.some((record) => record.role === "tool" ||
|
|
178
|
+
(record.role === "assistant" && Array.isArray(record.tool_calls) &&
|
|
179
|
+
record.tool_calls.length > 0)),
|
|
180
|
+
recordCount: result.records.length,
|
|
181
|
+
budgetMs: timeout.details.budget_ms,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** One-line human summary of hook-escape findings, or undefined when none. */
|
|
185
|
+
export function describeHookEscapes(result) {
|
|
186
|
+
const escapes = result.hookEscapes ?? [];
|
|
187
|
+
if (escapes.length === 0)
|
|
188
|
+
return undefined;
|
|
189
|
+
const first = escapes[0];
|
|
190
|
+
return `safety hook notice: ${escapes.length} policy-denied tool call(s) did not come back as the managed hook's denial (first: ${first.tool_name} ${first.kind}). Detection evidence only — the call may have been rejected upstream before the hook ran, blocked by another hook, or executed because the hook drifted. Run /kimi:setup --check; details in \`result <jobId> --json\` (hook_escapes).`;
|
|
191
|
+
}
|
|
192
|
+
export function warnHookEscapes(result, commandType, stderr) {
|
|
193
|
+
const note = describeHookEscapes(result);
|
|
194
|
+
if (note !== undefined)
|
|
195
|
+
stderr.write(`[${commandType}] ${note}\n`);
|
|
196
|
+
}
|
|
197
|
+
export function warnInheritedBudget(job, stderr) {
|
|
198
|
+
if (job.budget_source === "inherited" && job.budget_ms !== null) {
|
|
199
|
+
stderr.write(`[${job.command_type}] budget ${formatBudgetDuration(job.budget_ms)} inherited from job ${job.resumed_from_job_id}; pass --budget to override\n`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { RuntimeError } from "../errors.js";
|
|
5
|
+
import { resolveKimiHome } from "../kimi-home.js";
|
|
6
|
+
import { upstreamSnakeToCamel } from "../native-v2-preflight.js";
|
|
7
|
+
import { parse as parseToml } from "../vendor/smol-toml/parse.js";
|
|
8
|
+
export async function runModelInventory(argv, context) {
|
|
9
|
+
if (argv.length > 1 || (argv.length === 1 && argv[0] !== "--json")) {
|
|
10
|
+
throw new RuntimeError("INVALID_ARGS", "Use setup --models [--json] without other setup flags.", "setup.models");
|
|
11
|
+
}
|
|
12
|
+
const configPath = path.join(resolveKimiHome(context.env, context.cwd), "config.toml");
|
|
13
|
+
let config = {};
|
|
14
|
+
let configPresent = true;
|
|
15
|
+
try {
|
|
16
|
+
// O_NONBLOCK avoids hanging on a misconfigured FIFO. Read at most the cap
|
|
17
|
+
// even if the file grows after stat; nothing is copied or written to disk.
|
|
18
|
+
const file = await open(configPath, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
19
|
+
try {
|
|
20
|
+
const stat = await file.stat();
|
|
21
|
+
const limit = 2 * 1024 * 1024;
|
|
22
|
+
if (!stat.isFile() || stat.size > limit)
|
|
23
|
+
throw new Error();
|
|
24
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
25
|
+
let size = 0;
|
|
26
|
+
while (size < buffer.length) {
|
|
27
|
+
const { bytesRead } = await file.read(buffer, size, buffer.length - size, null);
|
|
28
|
+
if (bytesRead === 0)
|
|
29
|
+
break;
|
|
30
|
+
size += bytesRead;
|
|
31
|
+
}
|
|
32
|
+
if (size > limit)
|
|
33
|
+
throw new Error();
|
|
34
|
+
config = normalized(parseToml(buffer.toString("utf8", 0, size)));
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await file.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === "ENOENT")
|
|
42
|
+
configPresent = false;
|
|
43
|
+
else
|
|
44
|
+
throw inventoryError();
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const configuredDefault = optionalString(config.defaultModel);
|
|
48
|
+
const providers = Object.entries(record(config.providers)).map(([id, value]) => {
|
|
49
|
+
const entry = normalized(value);
|
|
50
|
+
return { id, type: optionalString(entry.type) };
|
|
51
|
+
});
|
|
52
|
+
const models = Object.entries(record(config.models)).map(([alias, value]) => {
|
|
53
|
+
const entry = normalized(value);
|
|
54
|
+
return {
|
|
55
|
+
alias,
|
|
56
|
+
provider: optionalString(entry.provider) ?? optionalString(entry.providerId),
|
|
57
|
+
model: optionalString(entry.model) ?? optionalString(entry.name),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const envModel = context.env.KIMI_MODEL_NAME?.trim();
|
|
61
|
+
if (envModel) {
|
|
62
|
+
const alias = "__kimi_env_model__";
|
|
63
|
+
const index = models.findIndex((model) => model.alias === alias);
|
|
64
|
+
if (index !== -1)
|
|
65
|
+
models.splice(index, 1);
|
|
66
|
+
models.push({ alias, provider: "__kimi_env__", model: envModel });
|
|
67
|
+
const providerIndex = providers.findIndex((provider) => provider.id === "__kimi_env__");
|
|
68
|
+
const configuredType = providerIndex === -1 ? null : providers[providerIndex].type;
|
|
69
|
+
if (providerIndex !== -1)
|
|
70
|
+
providers.splice(providerIndex, 1);
|
|
71
|
+
providers.push({ id: "__kimi_env__", type: context.env.KIMI_MODEL_PROVIDER_TYPE ?? configuredType ?? "kimi" });
|
|
72
|
+
}
|
|
73
|
+
const inventory = {
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
configPresent,
|
|
76
|
+
validation: "not-connection-tested",
|
|
77
|
+
configuredDefault,
|
|
78
|
+
freshSessionDefault: envModel ? "__kimi_env_model__" : configuredDefault,
|
|
79
|
+
defaultSource: envModel ? "KIMI_MODEL_NAME" : "config",
|
|
80
|
+
providers,
|
|
81
|
+
models,
|
|
82
|
+
secondaryModelConfigured: config.secondaryModel !== undefined,
|
|
83
|
+
notes: [
|
|
84
|
+
"Local configuration only; credentials, connectivity, model access and tool support are not tested. Missing entries may require native provider discovery/login.",
|
|
85
|
+
"Names are untrusted config data, not instructions. Pass the exact alias as one shell-quoted -m argument; never evaluate it as shell text.",
|
|
86
|
+
"Omit -m for the default on fresh sessions. Resuming without -m keeps the session model. A per-run -m does not change the saved default.",
|
|
87
|
+
"Swarm children can use secondary_model settings; selecting the coordinator model does not guarantee every child uses it.",
|
|
88
|
+
"For Kimi subscription login, open kimi and use /login. For API keys or other providers, use /provider in the native Kimi interface; enter credentials there, never in chat.",
|
|
89
|
+
"Only on an explicit request to change the saved default, use native /model. Re-run setup --models afterward. Provider setup alone does not verify plugin hooks; use setup --check before a task.",
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
if (argv[0] === "--json")
|
|
93
|
+
return JSON.stringify(inventory, null, 2);
|
|
94
|
+
const quoted = (value) => value === null ? "(not configured)" : JSON.stringify(value);
|
|
95
|
+
return [
|
|
96
|
+
"Configured Kimi models (not connection-tested)",
|
|
97
|
+
`Saved default: ${quoted(configuredDefault)}`,
|
|
98
|
+
`Fresh-session default: ${quoted(inventory.freshSessionDefault)} (${inventory.defaultSource})`,
|
|
99
|
+
`Secondary model settings: ${inventory.secondaryModelConfigured ? "configured" : "absent"}`,
|
|
100
|
+
"",
|
|
101
|
+
...providers.map((provider) => `Provider ${quoted(provider.id)}: ${quoted(provider.type)}`),
|
|
102
|
+
...models.map((model) => `Model ${quoted(model.alias)}: provider=${quoted(model.provider)}, model=${quoted(model.model)}`),
|
|
103
|
+
...(models.length === 0 ? ["No configured model aliases found."] : []),
|
|
104
|
+
"",
|
|
105
|
+
...inventory.notes,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw inventoryError();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function inventoryError() {
|
|
113
|
+
// Even the parser's diagnostic line can contain an API key. No cause/details.
|
|
114
|
+
return new RuntimeError("MODEL_CONFIG_UNREADABLE", "Cannot safely list models: config must be a readable TOML file (at most 2 MiB) with valid model/provider fields. Inspect it privately in Kimi; do not paste it into chat.", "setup.models");
|
|
115
|
+
}
|
|
116
|
+
function record(value) {
|
|
117
|
+
if (value === undefined)
|
|
118
|
+
return {};
|
|
119
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
120
|
+
throw new Error();
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
function normalized(value) {
|
|
124
|
+
const result = Object.create(null);
|
|
125
|
+
for (const [key, item] of Object.entries(record(value))) {
|
|
126
|
+
const normalizedKey = upstreamSnakeToCamel(key);
|
|
127
|
+
// Don't guess which spelling wins in an ambiguous config.
|
|
128
|
+
if (Object.hasOwn(result, normalizedKey))
|
|
129
|
+
throw new Error();
|
|
130
|
+
result[normalizedKey] = item;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
function optionalString(value) {
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
return null;
|
|
137
|
+
if (typeof value !== "string")
|
|
138
|
+
throw new Error();
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { allocateJobId } from "../job-id.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createCliCancellationHandlers } from "../cli-cancellation.js";
|
|
4
|
+
import { runCliPromptWithBudget } from "../cli-client.js";
|
|
5
|
+
import { executionPlanFromPersisted, observedExecutionFields, persistedExecutionPlanFields, prepareKimiExecutionPlan, } from "../kimi-engine.js";
|
|
6
|
+
import { RuntimeError } from "../errors.js";
|
|
7
|
+
import { resolveRepoIdentity } from "../git.js";
|
|
8
|
+
import { digestPrompt, markJobCancelled, markJobFailed, sweepStaleJobs } from "../jobs.js";
|
|
9
|
+
import { JobStore } from "../job-store.js";
|
|
10
|
+
import { classifyManagedCommandFailure } from "../kimi-errors.js";
|
|
11
|
+
import { KIMI_PURSUE_DEFAULT_BUDGET_MS } from "../kimi-timeouts.js";
|
|
12
|
+
import { writeInvocationLogHeader } from "../logging.js";
|
|
13
|
+
import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
|
|
14
|
+
import { parsePursueArgs } from "../parsing.js";
|
|
15
|
+
import { readArtifact, renderManagedJobOutput, writeArtifact } from "../render.js";
|
|
16
|
+
import { hookRefusalDetails, hookRefusalRetryProtocol, maybeWarnHookMissing, verifyHookInstalled, } from "../hooks/install.js";
|
|
17
|
+
import { assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing, warnHookEscapes } from "./cli-helpers.js";
|
|
18
|
+
import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js";
|
|
19
|
+
// /kimi:pursue — experimental autonomous goal mode (kimi-code 0.8.0+).
|
|
20
|
+
// Runtime-foreground-only, with a mandatory finite wall-clock budget. No resume:
|
|
21
|
+
// goalId is distinct from sessionId. Rescue lineage and the "rescue" hook label
|
|
22
|
+
// apply the workspace write allowlist on every continuation turn, and the hook
|
|
23
|
+
// is mandatory. Status, result, and cancellation use the same rescue lineage.
|
|
24
|
+
const PURSUE_SUMMARY_MAX = 120;
|
|
25
|
+
const PURSUE_AGENT_PROFILE = "<goal-mode>";
|
|
26
|
+
/**
|
|
27
|
+
* Goal-terminal exits (3 = blocked, 6 = paused) are NOT failures, so
|
|
28
|
+
* `assertCliResultSuccess` never runs for them — which means a native-v2 plan
|
|
29
|
+
* whose child exited 3/6 emitting nothing would otherwise resolve with an
|
|
30
|
+
* empty artifact and unproven provenance (cli-client enforces marker-first on
|
|
31
|
+
* every emitted line and on a clean exit, but not on a silent non-zero exit).
|
|
32
|
+
* Require the marker on every non-aborted goal-terminal result of a v2 plan.
|
|
33
|
+
* (Kimi second-round finding 2.)
|
|
34
|
+
*/
|
|
35
|
+
export function assertGoalRunProvenance(plan, result) {
|
|
36
|
+
if (plan.intendedEngine !== "native-v2" || result.aborted || result.systemVersion !== undefined) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
throw new RuntimeError("CLI_ENGINE_PROVENANCE_MISMATCH", `Refusing pursue result: the execution plan required native-v2, but the child exited ${String(result.exitCode)} without the system.version marker, so the engine that ran cannot be established.`, "pursue.provenance", {
|
|
40
|
+
details: {
|
|
41
|
+
operation_kind: "pursue",
|
|
42
|
+
intended_engine: "native-v2",
|
|
43
|
+
observed_engine: result.observedEngine ?? null,
|
|
44
|
+
exit_code: result.exitCode,
|
|
45
|
+
retryable_after_setup: false,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Map a headless goal-mode exit code to a terminal status. complete=0 is
|
|
51
|
+
* success; blocked=3 and paused=6 are terminal non-failures; anything else is
|
|
52
|
+
* a genuine process failure. Source:
|
|
53
|
+
* apps/kimi-code/src/cli/goal-prompt.ts::GOAL_EXIT_CODES.
|
|
54
|
+
*/
|
|
55
|
+
export function classifyGoalExit(exitCode) {
|
|
56
|
+
switch (exitCode) {
|
|
57
|
+
case 0:
|
|
58
|
+
return "complete";
|
|
59
|
+
case 3:
|
|
60
|
+
return "blocked";
|
|
61
|
+
case 6:
|
|
62
|
+
return "paused";
|
|
63
|
+
default:
|
|
64
|
+
return "unknown";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build the headless goal prompt. The objective becomes the goal; an optional
|
|
69
|
+
* turn cap is appended as a model INSTRUCTION (soft — headless create has no
|
|
70
|
+
* argv/env to set a hard turn budget). The hard bound is always the
|
|
71
|
+
* wall-clock AbortController; goal budget mutation is not authorized.
|
|
72
|
+
*/
|
|
73
|
+
export function buildGoalPrompt(objective, turns) {
|
|
74
|
+
const trimmed = objective.trim().split(/\s+/).join(" ");
|
|
75
|
+
const turnHint = turns !== undefined
|
|
76
|
+
? ` The plugin owns the outer wall-clock budget; do not call SetGoalBudget. Stop after at most ${turns} turns (soft instruction). Unfinished work at the soft stop is not completion.`
|
|
77
|
+
: "";
|
|
78
|
+
if (trimmed.length + turnHint.length > 4_000) {
|
|
79
|
+
throw new RuntimeError("INVALID_ARGS", turns === undefined
|
|
80
|
+
? "pursue objective exceeds Kimi's 4000-character goal limit; shorten the objective."
|
|
81
|
+
: "pursue objective plus --turns guidance exceeds Kimi's 4000-character goal limit; shorten the objective or omit --turns.", "pursue.parse");
|
|
82
|
+
}
|
|
83
|
+
return `/goal -- ${trimmed}${turnHint}`;
|
|
84
|
+
}
|
|
85
|
+
function renderGoalHeader(summary, exitStatus) {
|
|
86
|
+
const status = summary?.status ?? exitStatus;
|
|
87
|
+
const lines = [`**Goal status:** ${status}`];
|
|
88
|
+
if (summary?.reason) {
|
|
89
|
+
lines.push(`**Reason:** ${summary.reason}`);
|
|
90
|
+
}
|
|
91
|
+
const metrics = [];
|
|
92
|
+
if (summary?.turnsUsed != null)
|
|
93
|
+
metrics.push(`turns: ${summary.turnsUsed}`);
|
|
94
|
+
if (summary?.tokensUsed != null)
|
|
95
|
+
metrics.push(`tokens: ${summary.tokensUsed}`);
|
|
96
|
+
if (summary?.wallClockMs != null) {
|
|
97
|
+
metrics.push(`wall-clock: ${Math.round(summary.wallClockMs / 1000)}s`);
|
|
98
|
+
}
|
|
99
|
+
if (metrics.length > 0) {
|
|
100
|
+
lines.push(`**Usage:** ${metrics.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
if (summary?.goalId) {
|
|
103
|
+
lines.push(`**Goal id:** \`${summary.goalId}\` (resume is not yet exposed for /kimi:pursue — see docs/safety.md)`);
|
|
104
|
+
}
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
export async function runPursue(argv, context) {
|
|
108
|
+
const parsed = parsePursueArgs(argv);
|
|
109
|
+
const objective = parsed.objective?.trim();
|
|
110
|
+
if (!objective) {
|
|
111
|
+
throw new RuntimeError("INVALID_ARGS", "/kimi:pursue requires an objective. Usage: /kimi:pursue [--budget 30m] [--turns N] [-m model] <objective>", "pursue.parse");
|
|
112
|
+
}
|
|
113
|
+
const paths = resolvePluginPaths(context.env);
|
|
114
|
+
await ensurePluginPaths(paths);
|
|
115
|
+
const repoIdentity = await resolveRepoIdentity(context.cwd);
|
|
116
|
+
const store = new JobStore(paths);
|
|
117
|
+
try {
|
|
118
|
+
await sweepStaleJobs(store, paths);
|
|
119
|
+
const executionPlan = await prepareKimiExecutionPlan({
|
|
120
|
+
operationKind: "pursue",
|
|
121
|
+
cwd: context.cwd,
|
|
122
|
+
env: context.env,
|
|
123
|
+
});
|
|
124
|
+
const prompt = buildGoalPrompt(objective, parsed.turns);
|
|
125
|
+
const jobId = allocateJobId(context);
|
|
126
|
+
const logPath = path.join(paths.logsDir, `pursue-${jobId}.jsonl`);
|
|
127
|
+
const job = store.createJob({
|
|
128
|
+
job_id: jobId,
|
|
129
|
+
repo_id: repoIdentity.repoId,
|
|
130
|
+
// Reuse the rescue lineage: same write-trust boundary + allowlist.
|
|
131
|
+
command_type: "rescue",
|
|
132
|
+
cwd: context.cwd,
|
|
133
|
+
model: parsed.model ?? null,
|
|
134
|
+
thinking: null,
|
|
135
|
+
thinking_effort: parsed.thinkingEffort ?? null,
|
|
136
|
+
background: false,
|
|
137
|
+
budget_ms: parsed.budgetMs ?? KIMI_PURSUE_DEFAULT_BUDGET_MS,
|
|
138
|
+
budget_source: parsed.budgetMs !== undefined ? "explicit" : "default",
|
|
139
|
+
pid: process.pid,
|
|
140
|
+
kimi_pid: null,
|
|
141
|
+
status: "running",
|
|
142
|
+
kimi_session_id: null,
|
|
143
|
+
...persistedExecutionPlanFields(executionPlan),
|
|
144
|
+
agent_profile: PURSUE_AGENT_PROFILE,
|
|
145
|
+
prompt_digest: digestPrompt(prompt),
|
|
146
|
+
summary: `[pursue] ${shorten(objective, PURSUE_SUMMARY_MAX)}`,
|
|
147
|
+
phase: "starting",
|
|
148
|
+
final_output_path: null,
|
|
149
|
+
stream_log_path: logPath,
|
|
150
|
+
error: null,
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
await writeInvocationLogHeader(logPath, {
|
|
154
|
+
commandType: "pursue",
|
|
155
|
+
kimiSessionId: "(pending)",
|
|
156
|
+
cwd: context.cwd,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
const classified = new RuntimeError("PURSUE_LOG_HEADER_FAILED", `Failed to write pursue invocation log header: ${error.message ?? String(error)}`, "pursue.log-header", error instanceof Error ? { cause: error } : undefined);
|
|
161
|
+
await markJobFailed(store, paths, job, classified, "Pursue failed.", { phase: "failed" });
|
|
162
|
+
throw classified;
|
|
163
|
+
}
|
|
164
|
+
const completed = await executePursueJob(job.job_id, prompt, objective, job.budget_ms, context);
|
|
165
|
+
if (!completed.final_output_path) {
|
|
166
|
+
throw new RuntimeError("PURSUE_RESULT_MISSING", "Pursue finished without a rendered result.", "pursue.result");
|
|
167
|
+
}
|
|
168
|
+
return readArtifact(completed.final_output_path);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
store.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function executePursueJob(jobId, prompt, objective, budgetMs, context) {
|
|
175
|
+
const paths = resolvePluginPaths(context.env);
|
|
176
|
+
await ensurePluginPaths(paths);
|
|
177
|
+
const store = new JobStore(paths);
|
|
178
|
+
const job = store.getJob(jobId);
|
|
179
|
+
if (!job) {
|
|
180
|
+
store.close();
|
|
181
|
+
throw new RuntimeError("JOB_NOT_FOUND", `Pursue job ${jobId} was not found.`, "pursue.worker");
|
|
182
|
+
}
|
|
183
|
+
// Pursue is write-capable AND autonomous — refuse without the PreToolUse hook,
|
|
184
|
+
// exactly like rescue. The hook gates every tool call in every goal
|
|
185
|
+
// continuation turn; without it, kimi -p auto-approves destructive ops.
|
|
186
|
+
if (context.env.KIMI_PLUGIN_CC_SKIP_HOOK_CHECK !== "1") {
|
|
187
|
+
const installStatus = await verifyHookInstalled(context.env);
|
|
188
|
+
if (!installStatus.installed) {
|
|
189
|
+
maybeWarnHookMissing(installStatus, "rescue", context.stderr);
|
|
190
|
+
const classified = new RuntimeError("PURSUE_HOOK_NOT_INSTALLED", [
|
|
191
|
+
"/kimi:pursue refuses to run without the kimi-plugin-cc PreToolUse hook.",
|
|
192
|
+
`Hook check failed: ${installStatus.reason ?? "unknown"}.`,
|
|
193
|
+
"Repair by running Claude Code /kimi:setup or Codex $kimi-setup, then retry.",
|
|
194
|
+
"KIMI_PLUGIN_CC_SKIP_HOOK_CHECK=1 is only for deliberate tests or diagnostics.",
|
|
195
|
+
hookRefusalRetryProtocol(context.env),
|
|
196
|
+
].join(" "), "pursue.hook-check", { details: hookRefusalDetails(installStatus) });
|
|
197
|
+
try {
|
|
198
|
+
return await markJobFailed(store, paths, job, classified, "Pursue failed.", { phase: "failed" });
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
store.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const handlers = createCliCancellationHandlers();
|
|
206
|
+
try {
|
|
207
|
+
const executionPlan = executionPlanFromPersisted(job);
|
|
208
|
+
store.updateRunningJob(job.job_id, { phase: "turn-running" });
|
|
209
|
+
const result = await runCliPromptWithBudget({
|
|
210
|
+
cwd: job.cwd,
|
|
211
|
+
// Enable headless goal mode for THIS spawn only (per-job env block —
|
|
212
|
+
// never exported to a shell). KIMI_PLUGIN_CC_CMD=rescue is overlaid by
|
|
213
|
+
// cli-client from commandLabel below so the write allowlist applies.
|
|
214
|
+
// The env flag gates goal mode on kimi-code 0.8–0.11; 0.12.0 removed the
|
|
215
|
+
// gate (PR #569) so it is now redundant there but harmless — unknown
|
|
216
|
+
// experimental flag ids resolve to undefined and never error. Kept for
|
|
217
|
+
// 0.8–0.11 compat. The /goal prompt prefix (buildGoalPrompt) is the
|
|
218
|
+
// actual trigger on every supported version.
|
|
219
|
+
env: { ...context.env, KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND: "1" },
|
|
220
|
+
command: executionPlan.command,
|
|
221
|
+
prefixArgs: [...executionPlan.prefixArgs],
|
|
222
|
+
executionPlan,
|
|
223
|
+
prompt,
|
|
224
|
+
commandLabel: "rescue",
|
|
225
|
+
trustedWorkspaceRoot: job.cwd,
|
|
226
|
+
model: job.model ?? undefined,
|
|
227
|
+
thinkingEffort: job.thinking_effort ?? undefined,
|
|
228
|
+
logPath: job.stream_log_path,
|
|
229
|
+
onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
|
|
230
|
+
signal: handlers.signal,
|
|
231
|
+
}, budgetMs, "pursue.prompt");
|
|
232
|
+
store.updateRunningJob(job.job_id, observedExecutionFields(result));
|
|
233
|
+
warnHookEscapes(result, "pursue", context.stderr);
|
|
234
|
+
if (handlers.cancelling) {
|
|
235
|
+
throw new RuntimeError("PURSUE_CANCELLED", "Pursue cancelled by user request.", "pursue.runtime");
|
|
236
|
+
}
|
|
237
|
+
const goalStatus = classifyGoalExit(result.exitCode);
|
|
238
|
+
// 0/3/6 are terminal goal states (complete/blocked/paused) — NOT failures.
|
|
239
|
+
// Any other exit code is a real process failure; assertCliResultSuccess
|
|
240
|
+
// raises the canonical aborted / CLI_NONZERO_EXIT error for it.
|
|
241
|
+
if (goalStatus === "unknown") {
|
|
242
|
+
assertCliResultSuccess(result, "pursue.runtime");
|
|
243
|
+
}
|
|
244
|
+
assertGoalRunProvenance(executionPlan, result);
|
|
245
|
+
if (result.sessionId !== undefined &&
|
|
246
|
+
result.sessionId.length > 0 &&
|
|
247
|
+
result.sessionId !== job.kimi_session_id) {
|
|
248
|
+
store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
|
|
249
|
+
}
|
|
250
|
+
await syncKimiSessionTitle({
|
|
251
|
+
promptText: objective,
|
|
252
|
+
env: context.env,
|
|
253
|
+
cwd: job.cwd,
|
|
254
|
+
sessionId: result.sessionId ?? job.kimi_session_id,
|
|
255
|
+
title: buildKimiSessionTitle("pursue", objective),
|
|
256
|
+
stderr: context.stderr,
|
|
257
|
+
});
|
|
258
|
+
warnIfSessionIdMissing(result, "pursue", job.job_id, context.stderr);
|
|
259
|
+
const header = renderGoalHeader(result.goalSummary, goalStatus);
|
|
260
|
+
const prose = reassembleProseFromRecords(result.records);
|
|
261
|
+
const finalText = `${header}\n\n---\n\n${prose}`.trimEnd();
|
|
262
|
+
const rendered = renderManagedJobOutput(job, finalText);
|
|
263
|
+
let artifactPath;
|
|
264
|
+
try {
|
|
265
|
+
artifactPath = await writeArtifact(paths, job, rendered.rendered);
|
|
266
|
+
}
|
|
267
|
+
catch (writeError) {
|
|
268
|
+
context.stderr.write(`[kimi-plugin-cc] pursue artifact write failed for job ${job.job_id}; raw output preserved in error details.\n`);
|
|
269
|
+
const classified = new RuntimeError("PURSUE_ARTIFACT_WRITE_FAILED", `Failed to write pursue artifact: ${writeError.message ?? String(writeError)}`, "pursue.artifact", {
|
|
270
|
+
...(writeError instanceof Error ? { cause: writeError } : {}),
|
|
271
|
+
details: { rawOutput: finalText },
|
|
272
|
+
});
|
|
273
|
+
return await markJobFailed(store, paths, job, classified, "Pursue failed.", { phase: "failed" });
|
|
274
|
+
}
|
|
275
|
+
if (handlers.cancelling) {
|
|
276
|
+
throw new RuntimeError("PURSUE_CANCELLED", "Pursue cancelled after artifact write.", "pursue.runtime");
|
|
277
|
+
}
|
|
278
|
+
const goalSummaryStatus = result.goalSummary?.status ?? goalStatus;
|
|
279
|
+
return (store.markCompleted(job.job_id, {
|
|
280
|
+
summary: `[pursue:${goalSummaryStatus}] ${shorten(objective, PURSUE_SUMMARY_MAX)}`,
|
|
281
|
+
phase: "done",
|
|
282
|
+
final_output_path: artifactPath,
|
|
283
|
+
error: null,
|
|
284
|
+
}) ?? job);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (handlers.cancelling) {
|
|
288
|
+
const cancelledError = new RuntimeError("PURSUE_CANCELLED", "Pursue cancelled by user request.", "pursue.runtime", error instanceof Error ? { cause: error } : undefined);
|
|
289
|
+
return await markJobCancelled(store, paths, job, "Pursue cancelled by user request.", cancelledError, {
|
|
290
|
+
phase: "cancelled",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const classified = classifyManagedCommandFailure(error, "rescue", job.job_id, { preserveStage: true });
|
|
294
|
+
return await markJobFailed(store, paths, job, classified, "Pursue failed.", { phase: "failed" });
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
handlers.dispose();
|
|
298
|
+
store.close();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function shorten(text, max) {
|
|
302
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
303
|
+
if (normalized.length <= max) {
|
|
304
|
+
return normalized;
|
|
305
|
+
}
|
|
306
|
+
return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
|
307
|
+
}
|