kimi-plugin-cc 2.0.7
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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +32 -0
- package/CHANGELOG.md +1096 -0
- package/LICENSE +201 -0
- package/README.md +772 -0
- package/agents/kimi-ask.md +82 -0
- package/agents/kimi-challenge.md +82 -0
- package/agents/kimi-pursue.md +91 -0
- package/agents/kimi-rescue.md +85 -0
- package/agents/kimi-review.md +82 -0
- package/agents/kimi-swarm-write.md +92 -0
- package/agents/kimi-swarm.md +83 -0
- package/commands/README.md +16 -0
- package/commands/ask.md +32 -0
- package/commands/cancel.md +17 -0
- package/commands/challenge.md +29 -0
- package/commands/pursue.md +37 -0
- package/commands/replay.md +13 -0
- package/commands/rescue.md +32 -0
- package/commands/result.md +13 -0
- package/commands/review.md +29 -0
- package/commands/setup.md +23 -0
- package/commands/status.md +13 -0
- package/commands/swarm.md +38 -0
- package/dist/background-spawn.js +224 -0
- package/dist/cli-cancellation.js +43 -0
- package/dist/cli-client.js +845 -0
- package/dist/commands/ask.js +327 -0
- package/dist/commands/cancel.js +178 -0
- package/dist/commands/cli-helpers.js +112 -0
- package/dist/commands/models.js +140 -0
- package/dist/commands/not-implemented.js +4 -0
- package/dist/commands/pursue.js +311 -0
- package/dist/commands/registry.js +87 -0
- package/dist/commands/repair-sessions.js +159 -0
- package/dist/commands/replay.js +123 -0
- package/dist/commands/rescue.js +333 -0
- package/dist/commands/result.js +58 -0
- package/dist/commands/review-gate.js +393 -0
- package/dist/commands/review.js +218 -0
- package/dist/commands/setup.js +1132 -0
- package/dist/commands/status.js +27 -0
- package/dist/commands/swarm.js +576 -0
- package/dist/commands/worker.js +31 -0
- package/dist/companion.js +114 -0
- package/dist/config.js +22 -0
- package/dist/errors.js +40 -0
- package/dist/git.js +215 -0
- package/dist/hooks/approval-hook.js +112 -0
- package/dist/hooks/approval-policy.js +254 -0
- package/dist/hooks/config-safety.js +637 -0
- package/dist/hooks/install-paths.js +504 -0
- package/dist/hooks/install.js +281 -0
- package/dist/hooks/managed-block.js +745 -0
- package/dist/hooks/review-gate-stop.js +19 -0
- package/dist/job-store.js +559 -0
- package/dist/jobs.js +202 -0
- package/dist/kimi-command.js +72 -0
- package/dist/kimi-engine.js +624 -0
- package/dist/kimi-errors.js +220 -0
- package/dist/kimi-home.js +9 -0
- package/dist/kimi-timeouts.js +68 -0
- package/dist/kimi-version-probe.js +1410 -0
- package/dist/logging.js +22 -0
- package/dist/native-v2-preflight.js +417 -0
- package/dist/package.json +1 -0
- package/dist/parsing.js +553 -0
- package/dist/paths.js +27 -0
- package/dist/plugin-data.js +98 -0
- package/dist/process-tree.js +448 -0
- package/dist/render.js +159 -0
- package/dist/rescue-approval.js +951 -0
- package/dist/schemas/review-gate-output.js +43 -0
- package/dist/session-title.js +293 -0
- package/dist/stream-json.js +498 -0
- package/dist/types.js +1 -0
- package/dist/vendor/shell-quote/LICENSE +24 -0
- package/dist/vendor/shell-quote/index.js +1 -0
- package/dist/vendor/shell-quote/parse.js +229 -0
- package/dist/vendor/smol-toml/LICENSE +24 -0
- package/dist/vendor/smol-toml/date.js +127 -0
- package/dist/vendor/smol-toml/error.js +63 -0
- package/dist/vendor/smol-toml/extract.js +100 -0
- package/dist/vendor/smol-toml/parse.js +148 -0
- package/dist/vendor/smol-toml/primitive.js +179 -0
- package/dist/vendor/smol-toml/struct.js +184 -0
- package/dist/vendor/smol-toml/util.js +113 -0
- package/dist/version.js +7 -0
- package/docs/migration.md +215 -0
- package/docs/models.md +82 -0
- package/docs/registry-releases.md +70 -0
- package/docs/safety.md +318 -0
- package/docs/session-visibility.md +32 -0
- package/hooks/README.md +9 -0
- package/hooks/hooks.json +16 -0
- package/package.json +67 -0
- package/plugins/kimi-codex/.codex-plugin/plugin.json +39 -0
- package/plugins/kimi-codex/dist/background-spawn.js +224 -0
- package/plugins/kimi-codex/dist/cli-cancellation.js +43 -0
- package/plugins/kimi-codex/dist/cli-client.js +845 -0
- package/plugins/kimi-codex/dist/commands/ask.js +327 -0
- package/plugins/kimi-codex/dist/commands/cancel.js +178 -0
- package/plugins/kimi-codex/dist/commands/cli-helpers.js +112 -0
- package/plugins/kimi-codex/dist/commands/models.js +140 -0
- package/plugins/kimi-codex/dist/commands/not-implemented.js +4 -0
- package/plugins/kimi-codex/dist/commands/pursue.js +311 -0
- package/plugins/kimi-codex/dist/commands/registry.js +87 -0
- package/plugins/kimi-codex/dist/commands/repair-sessions.js +159 -0
- package/plugins/kimi-codex/dist/commands/replay.js +123 -0
- package/plugins/kimi-codex/dist/commands/rescue.js +333 -0
- package/plugins/kimi-codex/dist/commands/result.js +58 -0
- package/plugins/kimi-codex/dist/commands/review-gate.js +393 -0
- package/plugins/kimi-codex/dist/commands/review.js +218 -0
- package/plugins/kimi-codex/dist/commands/setup.js +1132 -0
- package/plugins/kimi-codex/dist/commands/status.js +27 -0
- package/plugins/kimi-codex/dist/commands/swarm.js +576 -0
- package/plugins/kimi-codex/dist/commands/worker.js +31 -0
- package/plugins/kimi-codex/dist/companion.js +114 -0
- package/plugins/kimi-codex/dist/config.js +22 -0
- package/plugins/kimi-codex/dist/errors.js +40 -0
- package/plugins/kimi-codex/dist/git.js +215 -0
- package/plugins/kimi-codex/dist/hooks/approval-hook.js +112 -0
- package/plugins/kimi-codex/dist/hooks/approval-policy.js +254 -0
- package/plugins/kimi-codex/dist/hooks/config-safety.js +637 -0
- package/plugins/kimi-codex/dist/hooks/install-paths.js +504 -0
- package/plugins/kimi-codex/dist/hooks/install.js +281 -0
- package/plugins/kimi-codex/dist/hooks/managed-block.js +745 -0
- package/plugins/kimi-codex/dist/hooks/review-gate-stop.js +19 -0
- package/plugins/kimi-codex/dist/job-store.js +559 -0
- package/plugins/kimi-codex/dist/jobs.js +202 -0
- package/plugins/kimi-codex/dist/kimi-command.js +72 -0
- package/plugins/kimi-codex/dist/kimi-engine.js +624 -0
- package/plugins/kimi-codex/dist/kimi-errors.js +220 -0
- package/plugins/kimi-codex/dist/kimi-home.js +9 -0
- package/plugins/kimi-codex/dist/kimi-timeouts.js +68 -0
- package/plugins/kimi-codex/dist/kimi-version-probe.js +1410 -0
- package/plugins/kimi-codex/dist/logging.js +22 -0
- package/plugins/kimi-codex/dist/native-v2-preflight.js +417 -0
- package/plugins/kimi-codex/dist/package.json +1 -0
- package/plugins/kimi-codex/dist/parsing.js +553 -0
- package/plugins/kimi-codex/dist/paths.js +27 -0
- package/plugins/kimi-codex/dist/plugin-data.js +98 -0
- package/plugins/kimi-codex/dist/process-tree.js +448 -0
- package/plugins/kimi-codex/dist/render.js +159 -0
- package/plugins/kimi-codex/dist/rescue-approval.js +951 -0
- package/plugins/kimi-codex/dist/schemas/review-gate-output.js +43 -0
- package/plugins/kimi-codex/dist/session-title.js +293 -0
- package/plugins/kimi-codex/dist/stream-json.js +498 -0
- package/plugins/kimi-codex/dist/types.js +1 -0
- package/plugins/kimi-codex/dist/vendor/shell-quote/LICENSE +24 -0
- package/plugins/kimi-codex/dist/vendor/shell-quote/index.js +1 -0
- package/plugins/kimi-codex/dist/vendor/shell-quote/parse.js +229 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/LICENSE +24 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/date.js +127 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/error.js +63 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/extract.js +100 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/parse.js +148 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/primitive.js +179 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/struct.js +184 -0
- package/plugins/kimi-codex/dist/vendor/smol-toml/util.js +113 -0
- package/plugins/kimi-codex/dist/version.js +7 -0
- package/plugins/kimi-codex/scripts/companion.sh +37 -0
- package/plugins/kimi-codex/scripts/review-gate-hook.sh +32 -0
- package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +31 -0
- package/plugins/kimi-codex/skills/kimi-ask/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-cancel/SKILL.md +25 -0
- package/plugins/kimi-codex/skills/kimi-cancel/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +31 -0
- package/plugins/kimi-codex/skills/kimi-challenge/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-pursue/SKILL.md +30 -0
- package/plugins/kimi-codex/skills/kimi-pursue/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-replay/SKILL.md +25 -0
- package/plugins/kimi-codex/skills/kimi-replay/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +30 -0
- package/plugins/kimi-codex/skills/kimi-rescue/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-result/SKILL.md +25 -0
- package/plugins/kimi-codex/skills/kimi-result/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-review/SKILL.md +31 -0
- package/plugins/kimi-codex/skills/kimi-review/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-setup/SKILL.md +32 -0
- package/plugins/kimi-codex/skills/kimi-setup/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-status/SKILL.md +25 -0
- package/plugins/kimi-codex/skills/kimi-status/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-swarm/SKILL.md +31 -0
- package/plugins/kimi-codex/skills/kimi-swarm/agents/openai.yaml +6 -0
- package/plugins/kimi-codex/skills/kimi-swarm-write/SKILL.md +32 -0
- package/plugins/kimi-codex/skills/kimi-swarm-write/agents/openai.yaml +6 -0
- package/scripts/companion.sh +37 -0
- package/scripts/review-gate-hook.sh +32 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
// Subprocess client for `kimi -p --output-format stream-json`.
|
|
2
|
+
//
|
|
3
|
+
// Replaces runtime/wire/client.ts for v1.0. Both modules coexist during the
|
|
4
|
+
// PR 1-3 transition; PR 4 deletes wire/. The contract is intentionally
|
|
5
|
+
// narrower than the v0.4 wire client — one process per prompt, no in-band
|
|
6
|
+
// approvals (PreToolUse hook handles those), no resumable JSON-RPC channel.
|
|
7
|
+
//
|
|
8
|
+
// Where v0.4 streamed turn events to a TurnCapture state machine,
|
|
9
|
+
// stream-json gives us flattened OpenAI-shaped records that callers
|
|
10
|
+
// reassemble at their own seam. The buffered result is the primary return
|
|
11
|
+
// shape; callers needing incremental visibility (e.g., updating SQLite
|
|
12
|
+
// phase as tools run) can pass `onRecord` to observe records as they arrive
|
|
13
|
+
// without paying for a separate event-stream API.
|
|
14
|
+
//
|
|
15
|
+
// Per-job env (`KIMI_PLUGIN_CC_CMD`) is set on this spawn so the
|
|
16
|
+
// PreToolUse hook can branch per command (review vs rescue vs ask). It is
|
|
17
|
+
// passed via the spawn `env` option, not exported to a shell, so concurrent
|
|
18
|
+
// jobs never share an env block.
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { RuntimeError, formatError } from "./errors.js";
|
|
23
|
+
import { assertNoUnsafeExperimentalSelector, assertExecutionPlanMatchesSpawn, } from "./kimi-engine.js";
|
|
24
|
+
import { assertPrefixArgsSafe } from "./kimi-command.js";
|
|
25
|
+
import { resolveKimiHome } from "./kimi-home.js";
|
|
26
|
+
import { assertNativeV2Preflight } from "./native-v2-preflight.js";
|
|
27
|
+
import { parseVersionLine } from "./kimi-version-probe.js";
|
|
28
|
+
import { StreamJsonParser, extractSessionIdFromStderr, } from "./stream-json.js";
|
|
29
|
+
import { collectDescendantIdentities, revalidateProcessIdentities, waitForProcessTreeExit, } from "./process-tree.js";
|
|
30
|
+
/** Bytes of stderr retained for diagnostics on completion. Rolling buffer. */
|
|
31
|
+
const STDERR_TAIL_BYTES = 8192;
|
|
32
|
+
/** Hard cap on awaiting diagnostics log drain before resolving the result. */
|
|
33
|
+
const LOG_DRAIN_TIMEOUT_MS = 250;
|
|
34
|
+
/**
|
|
35
|
+
* Default delay between SIGTERM and SIGKILL when an abort fires.
|
|
36
|
+
* Matches v0.4's `cancellation.ts` escalation timing for wire clients,
|
|
37
|
+
* so rescue/ask/review continue to feel identical to a stuck-kimi user
|
|
38
|
+
* after the v1.0 cutover.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_ESCALATION_MS = 1_500;
|
|
41
|
+
/** Maximum time spent confirming POSIX tree exit after SIGKILL dispatch. */
|
|
42
|
+
const POST_KILL_QUIESCENCE_MS = 500;
|
|
43
|
+
/** Hard bound for the one-shot descendant identity snapshot. */
|
|
44
|
+
const DESCENDANT_SNAPSHOT_TIMEOUT_MS = 1_000;
|
|
45
|
+
/** Hard bound for each batched identity revalidation pass. */
|
|
46
|
+
const IDENTITY_REVALIDATION_TIMEOUT_MS = 250;
|
|
47
|
+
const DESCENDANT_SNAPSHOT_TIMEOUT = Symbol("descendant-snapshot-timeout");
|
|
48
|
+
export async function runCliPrompt(opts) {
|
|
49
|
+
// Pre-aborted signal: refuse to spawn at all.
|
|
50
|
+
if (opts.signal?.aborted === true) {
|
|
51
|
+
throw new RuntimeError("CLI_ABORTED", "kimi subprocess request cancelled before spawn", "cli-client.pre-spawn", { details: { command: opts.command } });
|
|
52
|
+
}
|
|
53
|
+
assertExecutionPlanMatchesSpawn(opts.executionPlan, opts.command, opts.prefixArgs ?? [], opts.env);
|
|
54
|
+
if ((opts.commandLabel === "rescue" || opts.commandLabel === "swarm-write") &&
|
|
55
|
+
!opts.trustedWorkspaceRoot) {
|
|
56
|
+
throw new RuntimeError("CLI_TRUSTED_WORKSPACE_ROOT_MISSING", `Refusing ${opts.commandLabel}: write-capable plugin launches require a plugin-owned trusted workspace root.`, "cli-client.pre-spawn", { details: { command_label: opts.commandLabel } });
|
|
57
|
+
}
|
|
58
|
+
// The master experimental flag enables v2-only features (tower, subagent
|
|
59
|
+
// fork) outside the certified no-plan profile. Refuse it at the final
|
|
60
|
+
// boundary too, in case the environment changed after plan creation.
|
|
61
|
+
assertNoUnsafeExperimentalSelector(opts.env, "cli-client.pre-spawn");
|
|
62
|
+
// Validate the ACTUAL prefix argv used for this spawn — not only the
|
|
63
|
+
// env-resolved value — so a forged/corrupt persisted plan cannot smuggle a
|
|
64
|
+
// reserved kimi flag (e.g. `-r <session>`, which would resume a session the
|
|
65
|
+
// v2 preflight never scanned for plan taint).
|
|
66
|
+
assertPrefixArgsSafe(opts.prefixArgs ?? []);
|
|
67
|
+
if (opts.executionPlan.intendedEngine === "native-v2") {
|
|
68
|
+
// Re-run the cheap no-plan preconditions at the final boundary: the
|
|
69
|
+
// operator's config.toml and the resume journal are mutable between plan
|
|
70
|
+
// creation (possibly in another process) and this spawn.
|
|
71
|
+
await assertNativeV2Preflight({
|
|
72
|
+
kimiHome: resolveKimiHome(opts.env, opts.cwd),
|
|
73
|
+
env: opts.env,
|
|
74
|
+
resumeSessionId: opts.resumeSessionId,
|
|
75
|
+
stage: "cli-client.spawn",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const args = buildArgs(opts);
|
|
79
|
+
const env = buildEnv(opts);
|
|
80
|
+
if (opts.logPath !== undefined) {
|
|
81
|
+
await mkdir(path.dirname(opts.logPath), { recursive: true }).catch(() => {
|
|
82
|
+
// Best-effort; if mkdir fails the appendFile below will error and be
|
|
83
|
+
// swallowed by the log chain (diagnostics are non-blocking).
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
let child;
|
|
87
|
+
try {
|
|
88
|
+
child = spawn(opts.command, args, {
|
|
89
|
+
cwd: opts.cwd,
|
|
90
|
+
env,
|
|
91
|
+
stdio: "pipe",
|
|
92
|
+
// POSIX gets a fresh process group so cancellation reaches kimi's
|
|
93
|
+
// descendants; Windows has no negative-pid process-group signaling.
|
|
94
|
+
detached: process.platform !== "win32",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
throw new RuntimeError("CLI_SPAWN_FAILED", `Failed to spawn kimi: ${formatError(err)}`, "cli-client.spawn", err instanceof Error
|
|
99
|
+
? { cause: err, details: { command: opts.command, args } }
|
|
100
|
+
: { details: { command: opts.command, args } });
|
|
101
|
+
}
|
|
102
|
+
// Pre-attach an 'error' listener immediately. Bun (and Node) can emit
|
|
103
|
+
// 'error' asynchronously between spawn returning and our Promise body
|
|
104
|
+
// attaching the real listener — without a handler in place, EventEmitter
|
|
105
|
+
// crashes the process. Capture into an array (not a single slot) so two
|
|
106
|
+
// back-to-back errors don't lose the first; we replay the first one when
|
|
107
|
+
// the real handler installs. The replacement is atomic across two
|
|
108
|
+
// synchronous statements (flag-flip + .on attach), so no async work can
|
|
109
|
+
// interleave between them.
|
|
110
|
+
const earlyErrors = [];
|
|
111
|
+
let earlyErrorHandled = false;
|
|
112
|
+
child.on("error", function earlyErrorListener(err) {
|
|
113
|
+
if (earlyErrorHandled)
|
|
114
|
+
return;
|
|
115
|
+
earlyErrors.push(err);
|
|
116
|
+
});
|
|
117
|
+
const parser = new StreamJsonParser();
|
|
118
|
+
const records = [];
|
|
119
|
+
const malformed = [];
|
|
120
|
+
// Rolling stderr tail — keep only the trailing STDERR_TAIL_BYTES so a
|
|
121
|
+
// long-running kimi process emitting megabytes of stderr (tool.progress,
|
|
122
|
+
// thinking deltas) doesn't grow our RSS unboundedly.
|
|
123
|
+
let stderrTail = "";
|
|
124
|
+
let announcedSessionId;
|
|
125
|
+
let announcedGoalSummary;
|
|
126
|
+
let announcedSystemVersion;
|
|
127
|
+
let engineMismatchDetected = false;
|
|
128
|
+
let handleEngineMismatch;
|
|
129
|
+
const plannedEngine = opts.executionPlan.intendedEngine;
|
|
130
|
+
const plannedVersion = opts.executionPlan.kimiVersion === null
|
|
131
|
+
? null
|
|
132
|
+
: (parseVersionLine(opts.executionPlan.kimiVersion)?.raw ?? opts.executionPlan.kimiVersion);
|
|
133
|
+
let logChain = Promise.resolve();
|
|
134
|
+
const appendLogLine = (payload) => {
|
|
135
|
+
if (opts.logPath === undefined)
|
|
136
|
+
return;
|
|
137
|
+
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...payload })}\n`;
|
|
138
|
+
logChain = logChain.then(() => appendFile(opts.logPath, line, "utf8").catch(() => {
|
|
139
|
+
// Diagnostics best-effort; never crash on log failure.
|
|
140
|
+
}));
|
|
141
|
+
};
|
|
142
|
+
appendLogLine({
|
|
143
|
+
event: "spawn",
|
|
144
|
+
command: opts.command,
|
|
145
|
+
args,
|
|
146
|
+
cwd: opts.cwd,
|
|
147
|
+
command_label: opts.commandLabel ?? null,
|
|
148
|
+
operation_kind: opts.executionPlan.operationKind,
|
|
149
|
+
intended_engine: opts.executionPlan.intendedEngine,
|
|
150
|
+
kimi_version: opts.executionPlan.kimiVersion,
|
|
151
|
+
plan_certification: opts.executionPlan.certification,
|
|
152
|
+
safety_profile: opts.executionPlan.safetyProfile,
|
|
153
|
+
swarm_max_concurrency: opts.swarmMaxConcurrency ?? null,
|
|
154
|
+
legacy_v1_forced: env.KIMI_CODE_LEGACY_FLAG === KIMI_LEGACY_FORCED_VALUE,
|
|
155
|
+
});
|
|
156
|
+
const invokeOnRecord = (record) => {
|
|
157
|
+
if (opts.onRecord === undefined)
|
|
158
|
+
return;
|
|
159
|
+
try {
|
|
160
|
+
opts.onRecord(record);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Caller-supplied callback must never destabilize the parse loop.
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
// A native-v2 plan must see agent-core-v2's pre-tool system.version marker
|
|
167
|
+
// before any consumer-facing output; a legacy plan must never see it.
|
|
168
|
+
const requireMarkerBeforeOutput = (what) => {
|
|
169
|
+
if (plannedEngine !== "native-v2" || announcedSystemVersion !== undefined)
|
|
170
|
+
return false;
|
|
171
|
+
handleEngineMismatch?.({
|
|
172
|
+
observedEngine: null,
|
|
173
|
+
systemVersion: undefined,
|
|
174
|
+
reason: `${what} arrived before the native-v2 system.version marker; the engine that started cannot be established.`,
|
|
175
|
+
});
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
const consumeOutcomes = (outcomes) => {
|
|
179
|
+
for (const outcome of outcomes) {
|
|
180
|
+
if (engineMismatchDetected)
|
|
181
|
+
return;
|
|
182
|
+
if (outcome.unknownRecord !== undefined) {
|
|
183
|
+
// H3 forward-compat: a stream-json line with a role we don't model
|
|
184
|
+
// (a future kimi-code role). Log it for diagnostics but keep it OUT of
|
|
185
|
+
// records[] and out of onRecord — consumers iterate records[] expecting
|
|
186
|
+
// assistant/tool only. Tolerated, not treated as malformed/error —
|
|
187
|
+
// EXCEPT that on a native-v2 plan nothing may precede the marker.
|
|
188
|
+
if (requireMarkerBeforeOutput(`an unknown-role (${outcome.unknownRecord.role}) record`)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
appendLogLine({
|
|
192
|
+
event: "unknown_record",
|
|
193
|
+
role: outcome.unknownRecord.role,
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (outcome.goalSummary !== undefined) {
|
|
198
|
+
if (requireMarkerBeforeOutput("goal.summary"))
|
|
199
|
+
return;
|
|
200
|
+
// Goal-mode summary is out-of-band metadata for our wrapper, not a
|
|
201
|
+
// consumer-facing record. First-announce wins (a run emits exactly one,
|
|
202
|
+
// at session end); capture and skip so records[] stays assistant/tool.
|
|
203
|
+
if (announcedGoalSummary === undefined) {
|
|
204
|
+
announcedGoalSummary = outcome.goalSummary;
|
|
205
|
+
appendLogLine({
|
|
206
|
+
event: "goal_summary",
|
|
207
|
+
goal_id: outcome.goalSummary.goalId,
|
|
208
|
+
status: outcome.goalSummary.status,
|
|
209
|
+
turns_used: outcome.goalSummary.turnsUsed,
|
|
210
|
+
tokens_used: outcome.goalSummary.tokensUsed,
|
|
211
|
+
wall_clock_ms: outcome.goalSummary.wallClockMs,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (outcome.record !== undefined) {
|
|
217
|
+
// Meta records are out-of-band wrapper metadata, never consumer-facing
|
|
218
|
+
// assistant/tool records. Capture session.resume_hint (first-announce
|
|
219
|
+
// wins, mirroring the stderr capture below for kimi 0.1.x), and skip
|
|
220
|
+
// every recognized meta type, including 0.23.5's turn.step.retrying and
|
|
221
|
+
// agent-core-v2's system.version. This keeps records[] and onRecord
|
|
222
|
+
// stable while preserving the resume hint as the primary session-id
|
|
223
|
+
// source under kimi 0.2.0+.
|
|
224
|
+
if (outcome.record.role === "meta") {
|
|
225
|
+
// Contract: on a native-v2 plan the system.version marker is the
|
|
226
|
+
// FIRST stream-json line. Any other meta (e.g. session.resume_hint)
|
|
227
|
+
// arriving before it violates the ordering and means the started
|
|
228
|
+
// engine cannot be established — refuse before pinning a session id.
|
|
229
|
+
if (outcome.record.type !== "system.version" &&
|
|
230
|
+
requireMarkerBeforeOutput(`a ${outcome.record.type} record`)) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (outcome.record.type === "session.resume_hint" &&
|
|
234
|
+
announcedSessionId === undefined) {
|
|
235
|
+
announcedSessionId = outcome.record.sessionId;
|
|
236
|
+
appendLogLine({
|
|
237
|
+
event: "session_announce",
|
|
238
|
+
source: "stream-json.meta",
|
|
239
|
+
session_id: outcome.record.sessionId,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else if (outcome.record.type === "system.version" &&
|
|
243
|
+
announcedSystemVersion === undefined) {
|
|
244
|
+
announcedSystemVersion = outcome.record.version;
|
|
245
|
+
appendLogLine({
|
|
246
|
+
event: "system_version",
|
|
247
|
+
source: "stream-json.meta",
|
|
248
|
+
version: outcome.record.version,
|
|
249
|
+
});
|
|
250
|
+
if (plannedEngine !== "native-v2") {
|
|
251
|
+
handleEngineMismatch?.({
|
|
252
|
+
observedEngine: "native-v2",
|
|
253
|
+
systemVersion: outcome.record.version,
|
|
254
|
+
reason: `the pre-tool system.version marker proves native-v2 ${outcome.record.version} started.`,
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// The marker's `version` is a structured field, not a CLI banner:
|
|
259
|
+
// require a single well-formed semver token. A multi-line or
|
|
260
|
+
// otherwise decorated value must not be normalized into a certified
|
|
261
|
+
// one by taking its first line.
|
|
262
|
+
const observedVersion = parseExactVersionToken(outcome.record.version);
|
|
263
|
+
if (observedVersion === undefined) {
|
|
264
|
+
handleEngineMismatch?.({
|
|
265
|
+
observedEngine: "native-v2",
|
|
266
|
+
systemVersion: outcome.record.version,
|
|
267
|
+
reason: `the system.version marker carries a malformed version value ${JSON.stringify(outcome.record.version)}; the started engine's version cannot be established.`,
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (plannedVersion !== null && observedVersion !== plannedVersion) {
|
|
272
|
+
handleEngineMismatch?.({
|
|
273
|
+
observedEngine: "native-v2",
|
|
274
|
+
systemVersion: outcome.record.version,
|
|
275
|
+
reason: `the system.version marker reports ${outcome.record.version}, but the plan certified ${plannedVersion}.`,
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (requireMarkerBeforeOutput(`a ${outcome.record.role} record`))
|
|
283
|
+
return;
|
|
284
|
+
records.push(outcome.record);
|
|
285
|
+
appendLogLine({ event: "record", record: outcome.record });
|
|
286
|
+
invokeOnRecord(outcome.record);
|
|
287
|
+
}
|
|
288
|
+
else if (outcome.malformedLine !== undefined) {
|
|
289
|
+
// Diagnostics-only in general, but on a native-v2 plan a line that is
|
|
290
|
+
// not the marker arriving first still violates marker-first: the
|
|
291
|
+
// engine that produced it cannot be established.
|
|
292
|
+
if (requireMarkerBeforeOutput("a malformed stream-json line"))
|
|
293
|
+
return;
|
|
294
|
+
const entry = {
|
|
295
|
+
line: truncateChars(outcome.malformedLine, 200),
|
|
296
|
+
reason: outcome.malformedReason ?? "unknown",
|
|
297
|
+
};
|
|
298
|
+
malformed.push(entry);
|
|
299
|
+
appendLogLine({ event: "malformed", ...entry });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
const errorDetails = () => ({
|
|
304
|
+
command: opts.command,
|
|
305
|
+
args,
|
|
306
|
+
command_label: opts.commandLabel ?? null,
|
|
307
|
+
});
|
|
308
|
+
return await new Promise((resolve, reject) => {
|
|
309
|
+
let aborted = false;
|
|
310
|
+
let cancellationTeardown;
|
|
311
|
+
let settlementStarted = false;
|
|
312
|
+
const settle = (kind, payload) => {
|
|
313
|
+
if (settlementStarted)
|
|
314
|
+
return;
|
|
315
|
+
settlementStarted = true;
|
|
316
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
317
|
+
// Abort teardown can outlive the direct child's close event while a
|
|
318
|
+
// detached descendant is still being enumerated or escalated. Never
|
|
319
|
+
// expose a result/error until that one owned teardown path completes.
|
|
320
|
+
void (cancellationTeardown ?? Promise.resolve({ ok: true })).then((teardown) => {
|
|
321
|
+
if (!teardown.ok) {
|
|
322
|
+
appendLogLine({
|
|
323
|
+
event: "cancellation_teardown_error",
|
|
324
|
+
message: formatError(teardown.error),
|
|
325
|
+
});
|
|
326
|
+
reject(new RuntimeError("CLI_CANCELLATION_TEARDOWN_FAILED", `kimi cancellation teardown failed: ${formatError(teardown.error)}`, "cli-client.cancellation", teardown.error instanceof Error
|
|
327
|
+
? { cause: teardown.error, details: errorDetails() }
|
|
328
|
+
: { details: errorDetails() }));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (kind === "resolve")
|
|
332
|
+
resolve(payload);
|
|
333
|
+
else
|
|
334
|
+
reject(payload);
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
const escalationMs = opts.escalationMs ?? DEFAULT_ESCALATION_MS;
|
|
338
|
+
const identityReader = opts.processIdentityReader;
|
|
339
|
+
let processExited = false;
|
|
340
|
+
child.once("exit", () => {
|
|
341
|
+
processExited = true;
|
|
342
|
+
});
|
|
343
|
+
const captureDescendantIdentities = async (childPid) => {
|
|
344
|
+
let snapshot;
|
|
345
|
+
try {
|
|
346
|
+
const collected = await raceWithValueTimeout((opts.descendantIdentityCollector ?? collectDescendantIdentities)(childPid), DESCENDANT_SNAPSHOT_TIMEOUT_MS, DESCENDANT_SNAPSHOT_TIMEOUT);
|
|
347
|
+
if (collected === DESCENDANT_SNAPSHOT_TIMEOUT) {
|
|
348
|
+
appendLogLine({
|
|
349
|
+
event: "descendant_collection_timeout",
|
|
350
|
+
timeout_ms: DESCENDANT_SNAPSHOT_TIMEOUT_MS,
|
|
351
|
+
});
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
snapshot = collected;
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
appendLogLine({
|
|
358
|
+
event: "descendant_collection_error",
|
|
359
|
+
message: formatError(err),
|
|
360
|
+
});
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
const identities = [];
|
|
364
|
+
const seen = new Set();
|
|
365
|
+
for (const identity of snapshot) {
|
|
366
|
+
const key = `${identity.pid}:${identity.startToken}:${identity.processGroupId}`;
|
|
367
|
+
if (Number.isInteger(identity.pid) &&
|
|
368
|
+
identity.pid > 0 &&
|
|
369
|
+
identity.pid !== childPid &&
|
|
370
|
+
Number.isInteger(identity.processGroupId) &&
|
|
371
|
+
identity.processGroupId > 0 &&
|
|
372
|
+
identity.startToken.length > 0 &&
|
|
373
|
+
!seen.has(key)) {
|
|
374
|
+
seen.add(key);
|
|
375
|
+
identities.push({ ...identity });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return identities;
|
|
379
|
+
};
|
|
380
|
+
const trySignalPid = (pid, signal) => {
|
|
381
|
+
try {
|
|
382
|
+
process.kill(pid, signal);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
const ignored = isErrnoException(err, "ESRCH") || isErrnoException(err, "EPERM");
|
|
386
|
+
void ignored;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
const signalCapturedTree = async (childPid, identities, signal) => {
|
|
390
|
+
// The live ChildProcess anchors its detached group. Signal the group
|
|
391
|
+
// before the direct child can exit and make that numeric PGID stale.
|
|
392
|
+
if (!processExited) {
|
|
393
|
+
trySignalPid(-childPid, signal);
|
|
394
|
+
try {
|
|
395
|
+
child.kill(signal);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Best-effort direct-child signaling; ChildProcess owns this pid.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// One bounded pass replaces up to 512 sequential ps calls. A pid whose
|
|
402
|
+
// start token or PGID changed belongs to another process and is skipped.
|
|
403
|
+
const pidValidation = await revalidateProcessIdentities(identities, {
|
|
404
|
+
identityReader,
|
|
405
|
+
timeoutMs: IDENTITY_REVALIDATION_TIMEOUT_MS,
|
|
406
|
+
});
|
|
407
|
+
for (const current of pidValidation.identities) {
|
|
408
|
+
trySignalPid(current.pid, signal);
|
|
409
|
+
}
|
|
410
|
+
// Revalidate again immediately before signaling non-root groups. Never
|
|
411
|
+
// send to an unanchored numeric PGID left over from collection.
|
|
412
|
+
const groupValidation = await revalidateProcessIdentities(identities, {
|
|
413
|
+
identityReader,
|
|
414
|
+
timeoutMs: IDENTITY_REVALIDATION_TIMEOUT_MS,
|
|
415
|
+
});
|
|
416
|
+
const activeGroupIds = new Set(groupValidation.identities.map((identity) => identity.processGroupId));
|
|
417
|
+
activeGroupIds.delete(childPid);
|
|
418
|
+
for (const processGroupId of activeGroupIds) {
|
|
419
|
+
trySignalPid(-processGroupId, signal);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const teardownChildTree = async () => {
|
|
423
|
+
await opts.cancellationTeardownHook?.();
|
|
424
|
+
const childPid = child.pid;
|
|
425
|
+
if (childPid === undefined || processExited)
|
|
426
|
+
return;
|
|
427
|
+
const finiteEscalation = Number.isFinite(escalationMs);
|
|
428
|
+
if (process.platform === "win32") {
|
|
429
|
+
// Preserve the documented win32 behavior: only the direct child is
|
|
430
|
+
// managed; descendant enumeration/reaping remains unsupported.
|
|
431
|
+
try {
|
|
432
|
+
child.kill("SIGTERM");
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Best-effort.
|
|
436
|
+
}
|
|
437
|
+
const exited = await waitForProcessTreeExit([], escalationMs, {
|
|
438
|
+
isRootAlive: () => !processExited,
|
|
439
|
+
});
|
|
440
|
+
if (!exited && finiteEscalation) {
|
|
441
|
+
try {
|
|
442
|
+
child.kill("SIGKILL");
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// Best-effort.
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const identities = await captureDescendantIdentities(childPid);
|
|
451
|
+
await signalCapturedTree(childPid, identities, "SIGTERM");
|
|
452
|
+
// The grace interval starts only after SIGTERM has reached every
|
|
453
|
+
// revalidated target. Slow process enumeration never consumes the grace.
|
|
454
|
+
const exited = await waitForProcessTreeExit(identities, escalationMs, {
|
|
455
|
+
identityReader,
|
|
456
|
+
isRootAlive: () => !processExited,
|
|
457
|
+
});
|
|
458
|
+
if (!exited && finiteEscalation) {
|
|
459
|
+
await signalCapturedTree(childPid, identities, "SIGKILL");
|
|
460
|
+
const quiescent = await waitForProcessTreeExit(identities, POST_KILL_QUIESCENCE_MS, {
|
|
461
|
+
identityReader,
|
|
462
|
+
isRootAlive: () => !processExited,
|
|
463
|
+
});
|
|
464
|
+
if (!quiescent) {
|
|
465
|
+
appendLogLine({
|
|
466
|
+
event: "post_kill_quiescence_timeout",
|
|
467
|
+
timeout_ms: POST_KILL_QUIESCENCE_MS,
|
|
468
|
+
descendant_count: identities.length,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
let engineMismatchError;
|
|
474
|
+
handleEngineMismatch = (mismatch) => {
|
|
475
|
+
if (engineMismatchError !== undefined)
|
|
476
|
+
return;
|
|
477
|
+
engineMismatchDetected = true;
|
|
478
|
+
engineMismatchError = new RuntimeError("CLI_ENGINE_PROVENANCE_MISMATCH", `Refusing kimi output: execution plan required ${plannedEngine}, but ${mismatch.reason}`, "cli-client.engine-provenance", {
|
|
479
|
+
details: {
|
|
480
|
+
operation_kind: opts.executionPlan.operationKind,
|
|
481
|
+
intended_engine: plannedEngine,
|
|
482
|
+
observed_engine: mismatch.observedEngine,
|
|
483
|
+
planned_kimi_version: opts.executionPlan.kimiVersion,
|
|
484
|
+
system_version: mismatch.systemVersion ?? null,
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
appendLogLine({
|
|
488
|
+
event: "engine_provenance_mismatch",
|
|
489
|
+
intended_engine: plannedEngine,
|
|
490
|
+
observed_engine: mismatch.observedEngine,
|
|
491
|
+
system_version: mismatch.systemVersion ?? null,
|
|
492
|
+
reason: mismatch.reason,
|
|
493
|
+
});
|
|
494
|
+
cancellationTeardown = teardownChildTree().then(() => ({ ok: true }), (error) => ({ ok: false, error }));
|
|
495
|
+
settle("reject", engineMismatchError);
|
|
496
|
+
};
|
|
497
|
+
const onAbort = () => {
|
|
498
|
+
if (aborted)
|
|
499
|
+
return;
|
|
500
|
+
aborted = true;
|
|
501
|
+
cancellationTeardown = teardownChildTree().then(() => ({ ok: true }), (error) => ({ ok: false, error }));
|
|
502
|
+
void cancellationTeardown.then((outcome) => {
|
|
503
|
+
if (outcome.ok)
|
|
504
|
+
return;
|
|
505
|
+
// A bug in teardown must neither strand the outer promise nor leave the
|
|
506
|
+
// owned root running. The controlled child anchors this emergency kill.
|
|
507
|
+
const childPid = child.pid;
|
|
508
|
+
if (childPid !== undefined && !processExited) {
|
|
509
|
+
if (process.platform !== "win32")
|
|
510
|
+
trySignalPid(-childPid, "SIGKILL");
|
|
511
|
+
try {
|
|
512
|
+
child.kill("SIGKILL");
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// Best-effort; settlement still surfaces the teardown failure.
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
settle("reject", new RuntimeError("CLI_CANCELLATION_TEARDOWN_FAILED", "kimi cancellation teardown failed", "cli-client.cancellation"));
|
|
519
|
+
});
|
|
520
|
+
};
|
|
521
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
522
|
+
// Re-check `aborted` AFTER attaching the listener. `await mkdir` above
|
|
523
|
+
// is the first synchronous yield point after the pre-spawn aborted
|
|
524
|
+
// check — if abort fired during that await, the signal is already in
|
|
525
|
+
// an aborted state by the time we reach addEventListener, which does
|
|
526
|
+
// NOT re-fire for already-aborted signals (per the AbortSignal spec).
|
|
527
|
+
// Without this re-check the spawned child is orphaned: SIGTERM/SIGKILL
|
|
528
|
+
// is never sent and the process keeps holding the SQLite row + model
|
|
529
|
+
// tokens. Audit report 28 (Codex H1) tracked this fix.
|
|
530
|
+
if (opts.signal?.aborted === true) {
|
|
531
|
+
onAbort();
|
|
532
|
+
}
|
|
533
|
+
// Swap the pre-attached early listener for the real handler. These two
|
|
534
|
+
// statements are synchronous and execute as one unit; no event can
|
|
535
|
+
// interleave between marking earlyErrorHandled and attaching the new
|
|
536
|
+
// listener.
|
|
537
|
+
earlyErrorHandled = true;
|
|
538
|
+
child.on("error", (err) => {
|
|
539
|
+
appendLogLine({ event: "process_error", message: formatError(err) });
|
|
540
|
+
settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess error: ${formatError(err)}`, "cli-client.process", err instanceof Error
|
|
541
|
+
? { cause: err, details: errorDetails() }
|
|
542
|
+
: { details: errorDetails() }));
|
|
543
|
+
});
|
|
544
|
+
if (earlyErrors.length > 0) {
|
|
545
|
+
const captured = earlyErrors[0];
|
|
546
|
+
queueMicrotask(() => {
|
|
547
|
+
appendLogLine({
|
|
548
|
+
event: "process_error",
|
|
549
|
+
message: formatError(captured),
|
|
550
|
+
early: true,
|
|
551
|
+
additional_count: earlyErrors.length - 1,
|
|
552
|
+
});
|
|
553
|
+
settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess error: ${formatError(captured)}`, "cli-client.process", { cause: captured, details: errorDetails() }));
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
child.on("close", async (exitCode, signal) => {
|
|
557
|
+
consumeOutcomes(parser.flush());
|
|
558
|
+
if (!engineMismatchDetected &&
|
|
559
|
+
exitCode === 0 &&
|
|
560
|
+
!aborted &&
|
|
561
|
+
requireMarkerBeforeOutput("a clean exit")) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
// The stderr resume-hint channel is the kimi 0.1.x fallback and exists
|
|
565
|
+
// only for legacy-v1 plans; a native-v2 plan pins a session id from the
|
|
566
|
+
// stdout meta record alone (after the marker), so stderr can never pin
|
|
567
|
+
// an id ahead of, or different from, the provenance-checked stream.
|
|
568
|
+
const sessionId = announcedSessionId ??
|
|
569
|
+
(plannedEngine === "legacy-v1" ? extractSessionIdFromStderr(stderrTail) : undefined);
|
|
570
|
+
appendLogLine({
|
|
571
|
+
event: "exit",
|
|
572
|
+
exit_code: exitCode,
|
|
573
|
+
signal,
|
|
574
|
+
session_id: sessionId,
|
|
575
|
+
malformed_count: malformed.length,
|
|
576
|
+
record_count: records.length,
|
|
577
|
+
aborted,
|
|
578
|
+
});
|
|
579
|
+
// Cap the log drain wait so a wedged disk can't block result delivery.
|
|
580
|
+
// The result itself is in memory; diagnostics are best-effort.
|
|
581
|
+
await raceWithTimeout(logChain, LOG_DRAIN_TIMEOUT_MS);
|
|
582
|
+
settle("resolve", {
|
|
583
|
+
sessionId,
|
|
584
|
+
goalSummary: announcedGoalSummary,
|
|
585
|
+
systemVersion: announcedSystemVersion,
|
|
586
|
+
observedEngine: announcedSystemVersion !== undefined
|
|
587
|
+
? "native-v2"
|
|
588
|
+
: plannedEngine === "legacy-v1" &&
|
|
589
|
+
(exitCode === 0 ||
|
|
590
|
+
records.length > 0 ||
|
|
591
|
+
sessionId !== undefined ||
|
|
592
|
+
announcedGoalSummary !== undefined)
|
|
593
|
+
? "legacy-v1"
|
|
594
|
+
: null,
|
|
595
|
+
records,
|
|
596
|
+
malformed,
|
|
597
|
+
stderrTail,
|
|
598
|
+
exitCode: exitCode ?? -1,
|
|
599
|
+
signal,
|
|
600
|
+
aborted,
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
// Stdin: kimi -p reads from argv, not stdin. End it immediately so the
|
|
604
|
+
// child never blocks. Stream method calls on a failed-spawn child can
|
|
605
|
+
// throw synchronously — guard so the 'error' event still wins.
|
|
606
|
+
try {
|
|
607
|
+
child.stdout.setEncoding("utf8");
|
|
608
|
+
child.stderr.setEncoding("utf8");
|
|
609
|
+
child.stdout.on("data", (chunk) => {
|
|
610
|
+
consumeOutcomes(parser.push(chunk));
|
|
611
|
+
});
|
|
612
|
+
child.stderr.on("data", (chunk) => {
|
|
613
|
+
// First-announce-wins across both channels: once a session id is
|
|
614
|
+
// pinned (from the stdout meta record or an earlier stderr line),
|
|
615
|
+
// never let a later stderr line overwrite it. The stderr channel is
|
|
616
|
+
// the 0.1.x fallback and is consulted ONLY for legacy-v1 plans — on a
|
|
617
|
+
// native-v2 plan a stderr line could otherwise pin a session id before
|
|
618
|
+
// (or different from) the provenance-checked stdout stream.
|
|
619
|
+
if (plannedEngine === "legacy-v1" && announcedSessionId === undefined) {
|
|
620
|
+
const nextSessionId = extractSessionIdFromStderr(stderrTail + chunk);
|
|
621
|
+
if (nextSessionId !== undefined) {
|
|
622
|
+
announcedSessionId = nextSessionId;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
stderrTail = appendToTail(stderrTail, chunk, STDERR_TAIL_BYTES);
|
|
626
|
+
});
|
|
627
|
+
child.stdin.end();
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
settle("reject", new RuntimeError("CLI_PROCESS_ERROR", `kimi subprocess stream setup failed: ${formatError(err)}`, "cli-client.streams", err instanceof Error
|
|
631
|
+
? { cause: err, details: errorDetails() }
|
|
632
|
+
: { details: errorDetails() }));
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Helper for callers that require a session id (replay-capable commands).
|
|
638
|
+
* Throws CLI_NO_SESSION_ID if the result lacks one, embedding the command
|
|
639
|
+
* label and exit code in details so the failure is actionable in logs.
|
|
640
|
+
*
|
|
641
|
+
* Use this AT the call site rather than baking the requirement into
|
|
642
|
+
* runCliPrompt — some callers (setup probes, smoke tests) are fine with
|
|
643
|
+
* an undefined sessionId.
|
|
644
|
+
*/
|
|
645
|
+
export function requireSessionId(result, context) {
|
|
646
|
+
if (result.sessionId !== undefined && result.sessionId.length > 0) {
|
|
647
|
+
return result.sessionId;
|
|
648
|
+
}
|
|
649
|
+
throw new RuntimeError("CLI_NO_SESSION_ID", "kimi subprocess completed without announcing a session id; replay and resume will be unavailable", "cli-client.session-id", {
|
|
650
|
+
details: {
|
|
651
|
+
command_label: context.commandLabel ?? null,
|
|
652
|
+
exit_code: result.exitCode,
|
|
653
|
+
signal: result.signal,
|
|
654
|
+
aborted: result.aborted,
|
|
655
|
+
stderr_tail_len: result.stderrTail.length,
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Run `kimi -p` under a budget that also tells the subprocess to die
|
|
661
|
+
* when the budget expires.
|
|
662
|
+
*
|
|
663
|
+
* Why this exists separately from `withTimeout`:
|
|
664
|
+
*
|
|
665
|
+
* The generic `withTimeout` (runtime/kimi-timeouts.ts) only rejects
|
|
666
|
+
* the outer Promise — it has no handle on the kimi child process.
|
|
667
|
+
* review/ask/review-gate previously wrapped `runCliPrompt` with
|
|
668
|
+
* `withTimeout` and let the orphaned subprocess keep running until
|
|
669
|
+
* it crashed or finished on its own. The reviewers (reports 17 +
|
|
670
|
+
* 18) flagged this as critical for review_gate in particular: the
|
|
671
|
+
* 8 s budget fires inside Claude Code's Stop hook, the parent
|
|
672
|
+
* returns "allow stop", and a runaway kimi keeps holding the
|
|
673
|
+
* SQLite row + model tokens. Subsequent `/kimi:cancel` can't reach
|
|
674
|
+
* it because `kimi_pid` is null.
|
|
675
|
+
*
|
|
676
|
+
* Behavior:
|
|
677
|
+
*
|
|
678
|
+
* - Owns an internal AbortController. If the caller passes
|
|
679
|
+
* `opts.signal`, both signals are linked — abort on either side
|
|
680
|
+
* aborts the internal controller.
|
|
681
|
+
* - When the budget expires, abort the internal controller, await the
|
|
682
|
+
* subprocess/tree teardown, then reject with
|
|
683
|
+
* `RuntimeError("RESPONSE_TIMEOUT", …)` matching the legacy
|
|
684
|
+
* `withTimeout` code shape.
|
|
685
|
+
* - Result success path is unchanged from `runCliPrompt`.
|
|
686
|
+
*/
|
|
687
|
+
export async function runCliPromptWithBudget(opts, budgetMs, stage) {
|
|
688
|
+
const controller = new AbortController();
|
|
689
|
+
if (opts.signal?.aborted === true) {
|
|
690
|
+
controller.abort();
|
|
691
|
+
}
|
|
692
|
+
const onParentAbort = () => controller.abort();
|
|
693
|
+
opts.signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
694
|
+
let timeoutFired = false;
|
|
695
|
+
let timer;
|
|
696
|
+
timer = setTimeout(() => {
|
|
697
|
+
timeoutFired = true;
|
|
698
|
+
controller.abort();
|
|
699
|
+
}, budgetMs);
|
|
700
|
+
timer.unref();
|
|
701
|
+
const timeoutError = (raceDetected = false) => new RuntimeError("RESPONSE_TIMEOUT", `${stage} timed out after ${budgetMs}ms${raceDetected ? " (race detected post-result)" : ""}.`, stage, {
|
|
702
|
+
details: {
|
|
703
|
+
budget_ms: budgetMs,
|
|
704
|
+
command_label: opts.commandLabel ?? null,
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
try {
|
|
708
|
+
let result;
|
|
709
|
+
try {
|
|
710
|
+
// Deliberately do not race this promise against an early timeout
|
|
711
|
+
// rejection. The timer aborts it, and runCliPrompt owns the complete
|
|
712
|
+
// child/descendant teardown before it can settle.
|
|
713
|
+
result = await runCliPrompt({ ...opts, signal: controller.signal });
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
if (timeoutFired)
|
|
717
|
+
throw timeoutError();
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
if (timeoutFired) {
|
|
721
|
+
// The timer and close can become runnable in the same event-loop turn.
|
|
722
|
+
// Teardown has still completed because runCliPrompt settled first.
|
|
723
|
+
throw timeoutError(true);
|
|
724
|
+
}
|
|
725
|
+
return result;
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
if (timer !== undefined)
|
|
729
|
+
clearTimeout(timer);
|
|
730
|
+
opts.signal?.removeEventListener("abort", onParentAbort);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Parse a structured version FIELD (the system.version marker), not a CLI
|
|
735
|
+
* banner: the whole value must be one semver token (optional leading `v`,
|
|
736
|
+
* optional pre-release/build suffix) with no surrounding whitespace or extra
|
|
737
|
+
* lines. Returns the normalized `raw` form, or undefined when malformed.
|
|
738
|
+
*/
|
|
739
|
+
function parseExactVersionToken(value) {
|
|
740
|
+
if (/\s/.test(value) || value.length === 0)
|
|
741
|
+
return undefined;
|
|
742
|
+
return parseVersionLine(value)?.raw;
|
|
743
|
+
}
|
|
744
|
+
const KIMI_LEGACY_ENV = "KIMI_CODE_LEGACY_FLAG";
|
|
745
|
+
const KIMI_LEGACY_FORCED_VALUE = "1";
|
|
746
|
+
function buildArgs(opts) {
|
|
747
|
+
const args = [...(opts.prefixArgs ?? [])];
|
|
748
|
+
args.push("--output-format", "stream-json");
|
|
749
|
+
if (opts.resumeSessionId !== undefined) {
|
|
750
|
+
args.push("-r", opts.resumeSessionId);
|
|
751
|
+
}
|
|
752
|
+
if (opts.model !== undefined) {
|
|
753
|
+
args.push("-m", opts.model);
|
|
754
|
+
}
|
|
755
|
+
if (opts.skillsDirs !== undefined) {
|
|
756
|
+
for (const dir of opts.skillsDirs) {
|
|
757
|
+
args.push("--skills-dir", dir);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// NOTE: `opts.thinking === false` is intentionally NOT translated into a
|
|
761
|
+
// `--no-thinking` argv flag. kimi-code 0.1.1 rejects unknown options and
|
|
762
|
+
// has no per-spawn thinking control — the Round 2 multi-agent review
|
|
763
|
+
// caught this. The option stays in CliClientOptions for future wiring
|
|
764
|
+
// when upstream adds the flag (see field doc).
|
|
765
|
+
// -p accepts the prompt as the option value. We pass it as a separate argv
|
|
766
|
+
// entry so prompts with leading dashes or whitespace are not interpreted as
|
|
767
|
+
// additional flags.
|
|
768
|
+
args.push("-p", opts.prompt);
|
|
769
|
+
return args;
|
|
770
|
+
}
|
|
771
|
+
function buildEnv(opts) {
|
|
772
|
+
const env = { ...opts.env };
|
|
773
|
+
// kimi-code 0.33.0 inverted its CLI engine default: an unflagged `kimi -p`
|
|
774
|
+
// now selects native agent-core-v2, while this upstream-documented flag pins
|
|
775
|
+
// the legacy-v1 path. Native v2 still has a final-allow plan-file path that
|
|
776
|
+
// can skip our later external PreToolUse hook, so the engine choice must be a
|
|
777
|
+
// plugin-owned spawn invariant rather than ambient operator state. Older
|
|
778
|
+
// kimi-code releases ignore the unknown flag. Always overwrite ambient false
|
|
779
|
+
// values; accepting one would silently reopen v2 on 0.33+.
|
|
780
|
+
if (opts.executionPlan.intendedEngine === "legacy-v1") {
|
|
781
|
+
env[KIMI_LEGACY_ENV] = KIMI_LEGACY_FORCED_VALUE;
|
|
782
|
+
}
|
|
783
|
+
// The update preflight runs on the `-p` path too; a plugin spawn must never
|
|
784
|
+
// swap the certified binary underneath the execution plan.
|
|
785
|
+
env.KIMI_CODE_NO_AUTO_UPDATE = "1";
|
|
786
|
+
// Rescue and pursue share the write label. Only the immutable execution
|
|
787
|
+
// plan can grant pursue's narrow goal-metadata access; never trust ambient
|
|
788
|
+
// state inherited from another plugin job.
|
|
789
|
+
env.KIMI_PLUGIN_CC_OPERATION = opts.executionPlan.operationKind;
|
|
790
|
+
env.KIMI_CODE_HOME = resolveKimiHome(opts.env, opts.cwd);
|
|
791
|
+
if (opts.commandLabel !== undefined) {
|
|
792
|
+
env.KIMI_PLUGIN_CC_CMD = opts.commandLabel;
|
|
793
|
+
}
|
|
794
|
+
if (opts.swarmMaxConcurrency !== undefined) {
|
|
795
|
+
// kimi-code 0.18.0+ caps AgentSwarm's normal-phase concurrency at this
|
|
796
|
+
// value; older binaries ignore it. See the field doc on CliClientOptions.
|
|
797
|
+
env.KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY = String(opts.swarmMaxConcurrency);
|
|
798
|
+
}
|
|
799
|
+
if (opts.trustedWorkspaceRoot !== undefined) {
|
|
800
|
+
// Trusted allowlist root for write-capable PreToolUse cases. Forge-proof
|
|
801
|
+
// (env on the spawned process; the model cannot alter it). See field doc.
|
|
802
|
+
env.KIMI_PLUGIN_CC_WORKSPACE_ROOT = opts.trustedWorkspaceRoot;
|
|
803
|
+
}
|
|
804
|
+
return env;
|
|
805
|
+
}
|
|
806
|
+
function appendToTail(tail, chunk, maxBytes) {
|
|
807
|
+
const combined = tail + chunk;
|
|
808
|
+
if (combined.length <= maxBytes)
|
|
809
|
+
return combined;
|
|
810
|
+
return combined.slice(combined.length - maxBytes);
|
|
811
|
+
}
|
|
812
|
+
function truncateChars(value, maxChars) {
|
|
813
|
+
return value.length <= maxChars ? value : value.slice(0, maxChars);
|
|
814
|
+
}
|
|
815
|
+
function isErrnoException(err, code) {
|
|
816
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === code;
|
|
817
|
+
}
|
|
818
|
+
function raceWithTimeout(promise, timeoutMs) {
|
|
819
|
+
let timer;
|
|
820
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
821
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
822
|
+
});
|
|
823
|
+
return Promise.race([
|
|
824
|
+
promise.then(() => { }, () => { }),
|
|
825
|
+
timeoutPromise,
|
|
826
|
+
]).finally(() => {
|
|
827
|
+
if (timer !== undefined)
|
|
828
|
+
clearTimeout(timer);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async function raceWithValueTimeout(promise, timeoutMs, timeoutValue) {
|
|
832
|
+
let timer;
|
|
833
|
+
try {
|
|
834
|
+
return await Promise.race([
|
|
835
|
+
promise,
|
|
836
|
+
new Promise((resolve) => {
|
|
837
|
+
timer = setTimeout(() => resolve(timeoutValue), Math.max(0, timeoutMs));
|
|
838
|
+
}),
|
|
839
|
+
]);
|
|
840
|
+
}
|
|
841
|
+
finally {
|
|
842
|
+
if (timer !== undefined)
|
|
843
|
+
clearTimeout(timer);
|
|
844
|
+
}
|
|
845
|
+
}
|