kimi-plugin-cc 2.4.5 → 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.
Files changed (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +42 -26
  5. package/dist/dsh/companion.js +174 -0
  6. package/dist/dsh/index.js +156 -0
  7. package/dist/hooks/install-paths.js +2 -2
  8. package/dist/version.js +1 -1
  9. package/docs/models.md +5 -1
  10. package/docs/registry-releases.md +20 -17
  11. package/docs/safety.md +22 -9
  12. package/docs/upstream-2.4.1-certification.md +6 -0
  13. package/package.json +40 -6
  14. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  15. package/plugins/kimi-codex/dist/dsh/companion.js +174 -0
  16. package/plugins/kimi-codex/dist/dsh/index.js +156 -0
  17. package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
  18. package/plugins/kimi-codex/dist/version.js +1 -1
  19. package/plugins/kimi-dsh/LICENSE +201 -0
  20. package/plugins/kimi-dsh/README.md +80 -0
  21. package/plugins/kimi-dsh/cordis.patch.yml +45 -0
  22. package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
  23. package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
  24. package/plugins/kimi-dsh/dist/cli-client.js +876 -0
  25. package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
  26. package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
  27. package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
  28. package/plugins/kimi-dsh/dist/commands/models.js +140 -0
  29. package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
  30. package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
  31. package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
  32. package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
  33. package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
  34. package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
  35. package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
  36. package/plugins/kimi-dsh/dist/commands/result.js +66 -0
  37. package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
  38. package/plugins/kimi-dsh/dist/commands/review.js +220 -0
  39. package/plugins/kimi-dsh/dist/commands/setup.js +1047 -0
  40. package/plugins/kimi-dsh/dist/commands/status.js +27 -0
  41. package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
  42. package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
  43. package/plugins/kimi-dsh/dist/companion.js +97 -0
  44. package/plugins/kimi-dsh/dist/config.js +22 -0
  45. package/plugins/kimi-dsh/dist/dsh/companion.js +174 -0
  46. package/plugins/kimi-dsh/dist/dsh/index.js +156 -0
  47. package/plugins/kimi-dsh/dist/errors.js +40 -0
  48. package/plugins/kimi-dsh/dist/git.js +224 -0
  49. package/plugins/kimi-dsh/dist/help.js +43 -0
  50. package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
  51. package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
  52. package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
  53. package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
  54. package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
  55. package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
  56. package/plugins/kimi-dsh/dist/hooks/managed-block.js +619 -0
  57. package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
  58. package/plugins/kimi-dsh/dist/job-id.js +12 -0
  59. package/plugins/kimi-dsh/dist/job-store.js +659 -0
  60. package/plugins/kimi-dsh/dist/jobs.js +217 -0
  61. package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
  62. package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
  63. package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
  64. package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
  65. package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
  66. package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
  67. package/plugins/kimi-dsh/dist/logging.js +22 -0
  68. package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
  69. package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
  70. package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
  71. package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
  72. package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
  73. package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
  74. package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
  75. package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
  76. package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
  77. package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
  78. package/plugins/kimi-dsh/dist/model-command.js +32 -0
  79. package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
  80. package/plugins/kimi-dsh/dist/package.json +1 -0
  81. package/plugins/kimi-dsh/dist/parsing.js +620 -0
  82. package/plugins/kimi-dsh/dist/paths.js +27 -0
  83. package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
  84. package/plugins/kimi-dsh/dist/process-tree.js +479 -0
  85. package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
  86. package/plugins/kimi-dsh/dist/render.js +177 -0
  87. package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
  88. package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
  89. package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
  90. package/plugins/kimi-dsh/dist/session-title.js +293 -0
  91. package/plugins/kimi-dsh/dist/stream-json.js +346 -0
  92. package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
  93. package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
  94. package/plugins/kimi-dsh/dist/types.js +1 -0
  95. package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
  96. package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
  97. package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
  98. package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
  99. package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
  100. package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
  101. package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
  102. package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
  103. package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
  104. package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
  105. package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
  106. package/plugins/kimi-dsh/dist/version.js +7 -0
  107. package/plugins/kimi-dsh/package.json +48 -0
  108. package/plugins/kimi-dsh/scripts/companion.sh +37 -0
  109. package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
@@ -0,0 +1,336 @@
1
+ import { companionHelp } from "../help.js";
2
+ import { allocateJobId } from "../job-id.js";
3
+ import path from "node:path";
4
+ import { createCliCancellationHandlers } from "../cli-cancellation.js";
5
+ import { runCliPromptWithBudget } from "../cli-client.js";
6
+ import { assertPersistedResumeLineage, executionPlanFromPersisted, observedExecutionFields, persistedExecutionPlanFields, prepareKimiExecutionPlan, reconcileHistoricalJobProvenance, } from "../kimi-engine.js";
7
+ import { getManagedCommandConfig } from "./registry.js";
8
+ import { RuntimeError } from "../errors.js";
9
+ import { resolveRepoIdentity } from "../git.js";
10
+ import { digestPrompt, markJobCancelled, markJobFailed, sweepStaleJobs } from "../jobs.js";
11
+ import { JobStore } from "../job-store.js";
12
+ import { KIMI_ASK_PROMPT_TIMEOUT_MS } from "../kimi-timeouts.js";
13
+ import { writeInvocationLogHeader } from "../logging.js";
14
+ import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
15
+ import { parseAskArgs } from "../parsing.js";
16
+ import { readArtifact, renderManagedJobOutput, writeArtifact } from "../render.js";
17
+ import { classifyManagedCommandFailure } from "../kimi-errors.js";
18
+ import { startBackgroundJob } from "../background-spawn.js";
19
+ import { hookRefusalDetails, hookRefusalRetryProtocol, maybeWarnHookMissing, verifyHookInstalled, } from "../hooks/install.js";
20
+ import { warnInheritedBudget, captureTimeoutPartialOutput, assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing, warnHookEscapes } from "./cli-helpers.js";
21
+ import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js";
22
+ const ASK_SUMMARY_MAX = 120;
23
+ const ASK_AGENT_PROFILE_PLACEHOLDER = "<cli-client>";
24
+ const ASK_PROMPT_CONTRACT = [
25
+ "Answer directly in free-form prose.",
26
+ "For repository-dependent questions, inspect the relevant code with the allowed read tools and cite file:line for concrete code claims where useful.",
27
+ "Distinguish evidence from inference and state unknowns explicitly.",
28
+ "Do not use shell/Bash, writes, background tasks, or delegated operations.",
29
+ "Do not emit JSON unless the user explicitly asks for it.",
30
+ "Do not narrate tool progress; make the final response self-contained.",
31
+ ];
32
+ const ASK_PROMPT_PREFIX = [
33
+ ...ASK_PROMPT_CONTRACT,
34
+ "",
35
+ "User question:",
36
+ "",
37
+ ].join("\n");
38
+ const ASK_CONTINUE_TASK = "Continue the previous ask conversation and answer any outstanding question.";
39
+ const ASK_RESUME_PROMPT = [
40
+ ...ASK_PROMPT_CONTRACT,
41
+ "",
42
+ ASK_CONTINUE_TASK,
43
+ ].join("\n");
44
+ export async function runAsk(argv, context) {
45
+ const parsed = parseAskArgs(argv);
46
+ if (parsed.help)
47
+ return companionHelp(["ask", ...argv]);
48
+ if (context.env.KIMI_PLUGIN_CC_SKIP_HOOK_CHECK !== "1") {
49
+ await requireAskHookInstalled(context);
50
+ }
51
+ const paths = resolvePluginPaths(context.env);
52
+ await ensurePluginPaths(paths);
53
+ const repoIdentity = await resolveRepoIdentity(context.cwd);
54
+ const askConfig = getManagedCommandConfig("ask");
55
+ const store = new JobStore(paths);
56
+ try {
57
+ await sweepStaleJobs(store, paths);
58
+ const jobId = allocateJobId(context);
59
+ const sessionResolution = await resolveAskSession(store, repoIdentity.repoId, parsed.fresh, parsed.resume, parsed.resumeTarget);
60
+ const askPrompt = buildAskPrompt(parsed.prompt, sessionResolution.reusedSession);
61
+ const executionPlan = await prepareKimiExecutionPlan({
62
+ operationKind: "ask",
63
+ cwd: context.cwd,
64
+ env: context.env,
65
+ resumedFromJobId: sessionResolution.resumedFromJobId,
66
+ resumeSessionId: sessionResolution.kimiSessionId ?? undefined,
67
+ resumeSource: sessionResolution.sourceJob,
68
+ });
69
+ const logPath = path.join(paths.logsDir, `ask-${jobId}.jsonl`);
70
+ const job = store.createJob({
71
+ job_id: jobId,
72
+ repo_id: repoIdentity.repoId,
73
+ command_type: "ask",
74
+ cwd: context.cwd,
75
+ model: parsed.model ?? null,
76
+ thinking: null,
77
+ thinking_effort: parsed.thinkingEffort ?? null,
78
+ background: parsed.background,
79
+ budget_ms: parsed.budgetMs ?? (sessionResolution.sourceJob?.operation_kind === "ask" ? sessionResolution.sourceJob.budget_ms : null) ?? null,
80
+ budget_source: parsed.budgetMs !== undefined ? "explicit"
81
+ : sessionResolution.sourceJob?.operation_kind === "ask" && sessionResolution.sourceJob.budget_ms !== null ? "inherited" : "default",
82
+ pid: parsed.background ? null : process.pid,
83
+ kimi_pid: null,
84
+ status: "running",
85
+ kimi_session_id: sessionResolution.kimiSessionId,
86
+ kimi_session_id_source: sessionResolution.sourceJob?.kimi_session_id_source ?? null,
87
+ ...persistedExecutionPlanFields(executionPlan),
88
+ agent_profile: ASK_AGENT_PROFILE_PLACEHOLDER,
89
+ prompt_digest: digestPrompt(askPrompt),
90
+ summary: shorten(askPromptSubject(askPrompt), ASK_SUMMARY_MAX),
91
+ phase: parsed.background ? "queued" : "starting",
92
+ final_output_path: null,
93
+ stream_log_path: logPath,
94
+ error: null,
95
+ });
96
+ try {
97
+ await writeInvocationLogHeader(logPath, {
98
+ commandType: "ask",
99
+ kimiSessionId: sessionResolution.kimiSessionId ?? "(pending)",
100
+ cwd: context.cwd,
101
+ });
102
+ }
103
+ catch (error) {
104
+ const classified = new RuntimeError("ASK_LOG_HEADER_FAILED", `Failed to write ask invocation log header: ${error.message ?? String(error)}`, "ask.log-header", error instanceof Error ? { cause: error } : undefined);
105
+ await markJobFailed(store, paths, job, classified, askConfig.cancellation.failedSummary, { phase: "failed" });
106
+ throw classified;
107
+ }
108
+ warnInheritedBudget(job, context.stderr);
109
+ if (parsed.background) {
110
+ return startBackgroundJob(job, askPrompt, context, paths, {
111
+ workerKind: "ask",
112
+ wait: parsed.wait,
113
+ promptEnvVar: "KIMI_PLUGIN_CC_ASK_PROMPT_B64",
114
+ reusedSessionEnvVar: "KIMI_PLUGIN_CC_ASK_REUSED_SESSION",
115
+ reusedSession: sessionResolution.reusedSession,
116
+ failedSummary: askConfig.cancellation.failedSummary,
117
+ missingResultErrorCode: "ASK_RESULT_MISSING",
118
+ spawnFailedErrorCode: "ASK_WORKER_SPAWN_FAILED",
119
+ earlyExitErrorCode: "ASK_WORKER_EXITED_EARLY",
120
+ nodeBinInvalidErrorCode: "ASK_NODE_BIN_INVALID",
121
+ waitStage: "ask.wait",
122
+ spawnStage: "ask.worker.spawn",
123
+ });
124
+ }
125
+ const completed = await executeAskJob(job.job_id, askPrompt, context);
126
+ if (!completed.final_output_path) {
127
+ throw new RuntimeError("ASK_RESULT_MISSING", "Ask finished without a rendered result.", "ask.result");
128
+ }
129
+ return (await readArtifact(completed.final_output_path)).trimEnd();
130
+ }
131
+ finally {
132
+ store.close();
133
+ }
134
+ }
135
+ export async function executeAskJob(jobId, prompt, context, options) {
136
+ const paths = resolvePluginPaths(context.env);
137
+ await ensurePluginPaths(paths);
138
+ const store = new JobStore(paths);
139
+ const job = store.getJob(jobId);
140
+ if (!job) {
141
+ store.close();
142
+ throw new RuntimeError("JOB_NOT_FOUND", `Ask job ${jobId} was not found.`, "ask.worker");
143
+ }
144
+ const askConfig = getManagedCommandConfig("ask");
145
+ const cancel = askConfig.cancellation;
146
+ let handlers;
147
+ try {
148
+ // Re-verify in the worker so a background job cannot outlive or bypass
149
+ // enforcement drift between the user invocation and the actual Kimi spawn.
150
+ if (context.env.KIMI_PLUGIN_CC_SKIP_HOOK_CHECK !== "1") {
151
+ await requireAskHookInstalled(context);
152
+ }
153
+ handlers = createCliCancellationHandlers();
154
+ const executionPlan = executionPlanFromPersisted(job);
155
+ store.updateRunningJob(job.job_id, { phase: "turn-running" });
156
+ // Re-validate resume lineage from the store before spawning: a detached
157
+ // worker (or a forged queued row) reloads the persisted plan and would
158
+ // otherwise hand kimi_session_id to the spawn without the dispatch-time
159
+ // lineage check. Fresh sessions (no resumedFromJobId) are exempt.
160
+ if (job.kimi_session_id !== null) {
161
+ await assertPersistedResumeLineage(store, executionPlan, job.kimi_session_id, "ask");
162
+ }
163
+ if (options?.workerPid) {
164
+ store.updateRunningJob(job.job_id, { pid: options.workerPid, phase: "worker-running" });
165
+ }
166
+ const result = await runCliPromptWithBudget({
167
+ cwd: job.cwd,
168
+ env: context.env,
169
+ command: executionPlan.command,
170
+ prefixArgs: [...executionPlan.prefixArgs],
171
+ executionPlan,
172
+ prompt,
173
+ commandLabel: "ask",
174
+ model: job.model ?? undefined,
175
+ thinkingEffort: job.thinking_effort ?? undefined,
176
+ resumeSessionId: job.kimi_session_id ?? undefined,
177
+ logPath: job.stream_log_path,
178
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
179
+ signal: handlers.signal,
180
+ }, job.budget_ms ?? KIMI_ASK_PROMPT_TIMEOUT_MS, "ask.prompt");
181
+ store.updateRunningJob(job.job_id, observedExecutionFields(result));
182
+ if (handlers.cancelling) {
183
+ throw new RuntimeError(cancel.errorCodes.cancelled, cancel.cancelMessages.afterPrompt, "ask.runtime");
184
+ }
185
+ assertCliResultSuccess(result, "ask.runtime");
186
+ warnHookEscapes(result, "ask", context.stderr);
187
+ // Persist whichever session id kimi actually announced. For fresh
188
+ // sessions this is the only chance to record it. For resumed
189
+ // sessions it should match the input, but capture anyway in case
190
+ // kimi-code mints a new id on each turn.
191
+ if (result.sessionId !== undefined &&
192
+ result.sessionId.length > 0) {
193
+ // Never persist an empty ID because NULL represents a missing session ID.
194
+ store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
195
+ }
196
+ await syncKimiSessionTitle({
197
+ promptText: prompt,
198
+ env: context.env,
199
+ cwd: job.cwd,
200
+ sessionId: result.sessionId ?? job.kimi_session_id,
201
+ title: buildKimiSessionTitle("ask", askPromptSubject(prompt)),
202
+ stderr: context.stderr,
203
+ });
204
+ // For ask, missing session id breaks both -r (latest-resume) and
205
+ // explicit --resume <id>. Warn loudly so the user notices before
206
+ // they try to continue the conversation.
207
+ if (job.kimi_session_id === null) {
208
+ warnIfSessionIdMissing(result, "ask", job.job_id, context.stderr);
209
+ }
210
+ const finalText = reassembleProseFromRecords(result.records);
211
+ const rendered = renderManagedJobOutput(job, finalText);
212
+ const artifactPath = await writeArtifact(paths, job, rendered.rendered);
213
+ if (handlers.cancelling) {
214
+ throw new RuntimeError(cancel.errorCodes.cancelled, cancel.cancelMessages.afterArtifact, "ask.runtime");
215
+ }
216
+ return (store.markCompleted(job.job_id, {
217
+ summary: rendered.summary,
218
+ phase: "done",
219
+ final_output_path: artifactPath,
220
+ error: null,
221
+ }) ?? job);
222
+ }
223
+ catch (error) {
224
+ const classified = error instanceof RuntimeError && error.stage === "ask.hook-check" ? error
225
+ : classifyManagedCommandFailure(error, "ask", job.job_id, { background: job.background, budgetMs: job.budget_ms ?? KIMI_ASK_PROMPT_TIMEOUT_MS });
226
+ const partialOutput = await captureTimeoutPartialOutput(classified, store, job, context.env, handlers?.signal);
227
+ if (handlers?.cancelling) {
228
+ const cancelledError = new RuntimeError(cancel.errorCodes.cancelled, cancel.cancelMessages.default, "ask.runtime", error instanceof Error ? { cause: error } : undefined);
229
+ return await markJobCancelled(store, paths, job, cancel.cancelledSummary, cancelledError, { phase: "cancelled" });
230
+ }
231
+ // Hook preflight errors are already fully classified. Running them through
232
+ // the generic availability classifier can mistake words in the verifier's
233
+ // reason (for example "timed out") for a Kimi runtime timeout and erase the
234
+ // load-bearing hook-check code/stage from the persisted background job.
235
+ if (error instanceof RuntimeError && error.stage === "ask.hook-check") {
236
+ await markJobFailed(store, paths, job, error, cancel.failedSummary, { phase: "failed" });
237
+ throw error;
238
+ }
239
+ await markJobFailed(store, paths, job, classified, cancel.failedSummary, { phase: "failed", partialOutput });
240
+ throw classified;
241
+ }
242
+ finally {
243
+ handlers?.dispose();
244
+ store.close();
245
+ }
246
+ }
247
+ async function requireAskHookInstalled(context) {
248
+ if (context.env.KIMI_PLUGIN_CC_SKIP_HOOK_CHECK === "1") {
249
+ return;
250
+ }
251
+ const installStatus = await verifyHookInstalled(context.env);
252
+ if (installStatus.installed) {
253
+ return;
254
+ }
255
+ maybeWarnHookMissing(installStatus, "ask", context.stderr);
256
+ throw new RuntimeError("ASK_HOOK_NOT_INSTALLED", [
257
+ "ask refuses to run without the canonical kimi-plugin-cc PreToolUse hook.",
258
+ `Hook check failed: ${installStatus.reason ?? "unknown"}.`,
259
+ "Run /kimi:setup or $kimi-setup to install or repair this host's managed block.",
260
+ "Set KIMI_PLUGIN_CC_SKIP_HOOK_CHECK=1 only if you intentionally accept un-enforced execution.",
261
+ hookRefusalRetryProtocol(context.env),
262
+ ].join(" "), "ask.hook-check", { details: hookRefusalDetails(installStatus) });
263
+ }
264
+ async function resolveAskSession(store, repoId, fresh, resume, resumeTarget) {
265
+ if (fresh) {
266
+ return { kimiSessionId: null, reusedSession: false, resumedFromJobId: null, sourceJob: null };
267
+ }
268
+ if (resumeTarget) {
269
+ const byJob = store.getJob(resumeTarget);
270
+ const scoped = byJob?.repo_id === repoId && byJob.command_type === "ask" ? byJob : null;
271
+ const exact = scoped ?? store.findAskJobBySession(repoId, resumeTarget);
272
+ if (!exact) {
273
+ throw new RuntimeError("ASK_RESUME_NOT_FOUND", `No ask job or session matched ${resumeTarget}.`, "ask.resume");
274
+ }
275
+ // Order matters: surface ASK_ALREADY_RUNNING before complaining that
276
+ // the session id is missing. In v1.0 a running ask row carries
277
+ // kimi_session_id=NULL (kimi-code assigns the id post-call), so the
278
+ // legacy "no session id → not found" check would mask the more
279
+ // accurate "already running" error.
280
+ const source = await reconcileHistoricalJobProvenance(store, exact);
281
+ ensureAskSessionIsNotRunning(source);
282
+ if (!source.kimi_session_id) {
283
+ throw new RuntimeError("ASK_RESUME_NOT_FOUND", `No ask job or session matched ${resumeTarget}.`, "ask.resume");
284
+ }
285
+ return {
286
+ kimiSessionId: source.kimi_session_id,
287
+ reusedSession: true,
288
+ resumedFromJobId: source.job_id,
289
+ sourceJob: source,
290
+ };
291
+ }
292
+ if (resume) {
293
+ const latest = store.findLatestJob({ repoId, commandType: "ask" });
294
+ if (!latest) {
295
+ throw new RuntimeError("ASK_RESUME_NOT_FOUND", "No prior ask session exists for this repository.", "ask.resume");
296
+ }
297
+ const source = await reconcileHistoricalJobProvenance(store, latest);
298
+ ensureAskSessionIsNotRunning(source);
299
+ if (!source.kimi_session_id) {
300
+ throw new RuntimeError("ASK_RESUME_NOT_FOUND", "No prior ask session exists for this repository.", "ask.resume");
301
+ }
302
+ return {
303
+ kimiSessionId: source.kimi_session_id,
304
+ reusedSession: true,
305
+ resumedFromJobId: source.job_id,
306
+ sourceJob: source,
307
+ };
308
+ }
309
+ return { kimiSessionId: null, reusedSession: false, resumedFromJobId: null, sourceJob: null };
310
+ }
311
+ function ensureAskSessionIsNotRunning(job) {
312
+ if (job.status === "running") {
313
+ throw new RuntimeError("ASK_ALREADY_RUNNING", `Ask session ${job.kimi_session_id ?? "<unknown>"} is already active under job ${job.job_id}.`, "ask.resume");
314
+ }
315
+ }
316
+ function buildAskPrompt(question, reusedSession) {
317
+ if (question?.trim()) {
318
+ return ASK_PROMPT_PREFIX + question.trim();
319
+ }
320
+ if (reusedSession) {
321
+ return ASK_RESUME_PROMPT;
322
+ }
323
+ throw new RuntimeError("INVALID_ARGS", "ask requires a question after the flags.", "ask.parse");
324
+ }
325
+ function askPromptSubject(prompt) {
326
+ if (prompt.startsWith(ASK_PROMPT_PREFIX))
327
+ return prompt.slice(ASK_PROMPT_PREFIX.length);
328
+ return prompt === ASK_RESUME_PROMPT ? ASK_CONTINUE_TASK : prompt;
329
+ }
330
+ function shorten(text, max) {
331
+ const normalized = text.replace(/\s+/g, " ").trim();
332
+ if (normalized.length <= max) {
333
+ return normalized;
334
+ }
335
+ return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
336
+ }
@@ -0,0 +1,216 @@
1
+ import process from "node:process";
2
+ import { RuntimeError } from "../errors.js";
3
+ import { resolveRepoIdentity } from "../git.js";
4
+ import { markJobCancelled, sweepStaleJobs } from "../jobs.js";
5
+ import { JobStore, withJobStore } from "../job-store.js";
6
+ import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
7
+ import { parseJobLookupArgs } from "../parsing.js";
8
+ import { revalidateProcessIdentities } from "../process-tree.js";
9
+ import { getManagedCommandConfig } from "./registry.js";
10
+ export async function runCancel(argv, context) {
11
+ const parsed = parseJobLookupArgs(argv);
12
+ const paths = resolvePluginPaths(context.env);
13
+ await ensurePluginPaths(paths);
14
+ const repoIdentity = await resolveRepoIdentity(context.cwd);
15
+ const store = new JobStore(paths);
16
+ try {
17
+ await sweepStaleJobs(store, paths);
18
+ const job = parsed.jobId
19
+ ? store.getJob(parsed.jobId)
20
+ : store.findLatestJob({
21
+ repoId: repoIdentity.repoId,
22
+ commandType: parsed.type,
23
+ runningOnly: true,
24
+ });
25
+ if (!job) {
26
+ throw new RuntimeError("JOB_NOT_FOUND", "No running job was found to cancel.", "cancel.lookup");
27
+ }
28
+ if (job.status !== "running") {
29
+ return `${JSON.stringify({
30
+ job_id: job.job_id,
31
+ status: job.status,
32
+ message: "cancel is a no-op for terminal jobs.",
33
+ }, null, 2)}\n`;
34
+ }
35
+ if (store.getMcpTask(job.job_id) &&
36
+ (context.mcpCompanionIdentity === undefined || context.mcpKimiIdentity === undefined)) {
37
+ throw new RuntimeError("MCP_CANCEL_REQUIRES_BINDING", `MCP job ${job.job_id} requires MCP job-control cancellation with recorded identities; use tasks/cancel or kimi_job_cancel.`, "mcp.cancel");
38
+ }
39
+ const recovery = store.getSwarmRecovery(job.job_id)?.record;
40
+ if (recovery) {
41
+ const identity = recovery.owner;
42
+ const verified = identity && identity.pid === job.pid ? await revalidateProcessIdentities([identity]) : undefined;
43
+ if (!verified?.complete || verified.groupChanged?.length || verified.identities.length !== 1) {
44
+ throw new RuntimeError("SWARM_RECOVERY_REQUIRED", `Recorded swarm ownership is no longer live or verifiable. Preview companion.sh recover-swarm ${job.job_id}; no process was signalled.`, "cancel.identity");
45
+ }
46
+ }
47
+ if (job.pid === null && job.kimi_pid === null) {
48
+ throw new RuntimeError("CANCEL_NOT_SUPPORTED", `Job ${job.job_id} does not have a recorded process id to cancel.`, "cancel.runtime");
49
+ }
50
+ // review_gate runs in a Stop hook. Pre-mark the row as cancelled before
51
+ // signaling so the subprocess close/result path cannot persist a failure
52
+ // over the user's intent; later updates are no-ops on a terminal row.
53
+ //
54
+ // The terminal row also makes waitForCancellation return before its own
55
+ // escalation, so this path always attempts SIGTERM after the pre-mark
56
+ // attempt, waits 1s, and sends SIGKILL only if a recorded process remains
57
+ // live. A pre-mark failure is rethrown only after termination is attempted.
58
+ if (job.command_type === "review_gate") {
59
+ let preMarkError;
60
+ try {
61
+ await withJobStore(paths, async (preMarkStore) => {
62
+ const reviewGateCancel = getManagedCommandConfig("review_gate").cancellation;
63
+ await markJobCancelled(preMarkStore, paths, job, reviewGateCancel.cancelledSummary, new RuntimeError(reviewGateCancel.errorCodes.cancelled, reviewGateCancel.cancelMessages.default, "cancel.runtime"));
64
+ });
65
+ }
66
+ catch (error) {
67
+ preMarkError = error;
68
+ }
69
+ await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity);
70
+ signalJobProcesses(job, "SIGTERM");
71
+ await sleep(1_000);
72
+ if (hasLiveRecordedProcess(job)) {
73
+ signalJobProcesses(job, "SIGKILL");
74
+ }
75
+ if (preMarkError) {
76
+ throw preMarkError;
77
+ }
78
+ return `${JSON.stringify({
79
+ job_id: job.job_id,
80
+ status: "cancelled",
81
+ message: "review_gate cancelled; SIGTERM→SIGKILL escalation completed.",
82
+ }, null, 2)}\n`;
83
+ }
84
+ await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity);
85
+ await assertMcpKimiIdentity(job, context.mcpKimiIdentity);
86
+ if (recovery)
87
+ await assertMcpCompanionIdentity(job, recovery.owner);
88
+ // The live swarm owner owns child teardown, including web phases after print exit.
89
+ signalJobProcesses(job, "SIGTERM", { companion: true, kimi: !recovery });
90
+ const cancelled = await waitForCancellation(paths, job.job_id);
91
+ if (cancelled) {
92
+ return `${JSON.stringify({
93
+ job_id: cancelled.job_id,
94
+ status: cancelled.status,
95
+ message: "Cancellation completed.",
96
+ }, null, 2)}\n`;
97
+ }
98
+ const companionMatches = await assertMcpCompanionIdentity(job, recovery ? recovery.owner : context.mcpCompanionIdentity, true);
99
+ const kimiMatches = await assertMcpKimiIdentity(job, context.mcpKimiIdentity, true);
100
+ if (companionMatches || kimiMatches) {
101
+ signalJobProcesses(job, "SIGKILL", { companion: companionMatches, kimi: !recovery && kimiMatches });
102
+ }
103
+ await withJobStore(paths, async (forcedStore) => {
104
+ const current = forcedStore.getJob(job.job_id);
105
+ if (current?.status === "running") {
106
+ await markJobCancelled(forcedStore, paths, current, "Job was force-cancelled after recorded process termination.", undefined, current.command_type === "ask" || current.command_type === "rescue" ? { phase: "cancelled" } : undefined);
107
+ }
108
+ });
109
+ return `${JSON.stringify({
110
+ job_id: job.job_id,
111
+ status: "cancelled",
112
+ message: "Cancellation escalated to worker termination.",
113
+ }, null, 2)}\n`;
114
+ }
115
+ finally {
116
+ store.close();
117
+ }
118
+ }
119
+ async function assertMcpCompanionIdentity(job, expected, allowMissing = false) {
120
+ return assertMcpProcessIdentity(job.pid, expected, "companion", allowMissing);
121
+ }
122
+ async function assertMcpKimiIdentity(job, expected, allowMissing = false) {
123
+ return assertMcpProcessIdentity(job.kimi_pid, expected, "Kimi", allowMissing);
124
+ }
125
+ async function assertMcpProcessIdentity(actualPid, expected, label, allowMissing) {
126
+ if (expected === undefined)
127
+ return true;
128
+ if (expected === null) {
129
+ if (actualPid === null)
130
+ return false;
131
+ throw new RuntimeError("CANCEL_NOT_SUPPORTED", `MCP job ${actualPid} has no verified ${label} identity; refusing PID-only cancellation.`, "mcp.cancel");
132
+ }
133
+ if (actualPid !== expected.pid) {
134
+ throw new RuntimeError("MCP_PROCESS_IDENTITY_MISMATCH", `The recorded ${label} process identity no longer matches; no signal was sent.`, "mcp.cancel");
135
+ }
136
+ const validation = await revalidateProcessIdentities([expected]);
137
+ if (!validation.complete) {
138
+ throw new RuntimeError("MCP_PROCESS_IDENTITY_UNAVAILABLE", `The ${label} identity probe was inconclusive; no further signal was sent and the job was not changed.`, "mcp.cancel");
139
+ }
140
+ const matches = validation.identities.length > 0;
141
+ if (!matches && !allowMissing) {
142
+ throw new RuntimeError("MCP_PROCESS_IDENTITY_MISMATCH", `The recorded ${label} process identity no longer matches; no signal was sent.`, "mcp.cancel");
143
+ }
144
+ return matches;
145
+ }
146
+ function signalJobProcesses(job, signal, include = { companion: true, kimi: true }) {
147
+ const pids = new Set();
148
+ if (include.companion && job.pid !== null)
149
+ pids.add(job.pid);
150
+ if (include.kimi && job.kimi_pid !== null)
151
+ pids.add(job.kimi_pid);
152
+ for (const pid of pids) {
153
+ try {
154
+ process.kill(pid, signal);
155
+ }
156
+ catch (error) {
157
+ const code = error.code;
158
+ // ESRCH: process already gone. EPERM: pid was likely reused by an
159
+ // unrelated process we don't own — silently skip rather than surface a
160
+ // confusing "operation not permitted" to the user.
161
+ if (code !== "ESRCH" && code !== "EPERM") {
162
+ throw error;
163
+ }
164
+ }
165
+ }
166
+ }
167
+ function sleep(ms) {
168
+ return new Promise((resolve) => {
169
+ setTimeout(resolve, ms);
170
+ });
171
+ }
172
+ async function waitForCancellation(paths, jobId) {
173
+ const deadline = Date.now() + 4_000;
174
+ while (Date.now() < deadline) {
175
+ const store = new JobStore(paths);
176
+ try {
177
+ const current = store.getJob(jobId);
178
+ if (current && current.status !== "running") {
179
+ return current;
180
+ }
181
+ if (current && !hasLiveRecordedProcess(current)) {
182
+ return null;
183
+ }
184
+ }
185
+ finally {
186
+ store.close();
187
+ }
188
+ await new Promise((resolve) => {
189
+ setTimeout(resolve, 200);
190
+ });
191
+ }
192
+ return null;
193
+ }
194
+ function hasLiveRecordedProcess(job) {
195
+ const pids = new Set();
196
+ if (job.pid !== null)
197
+ pids.add(job.pid);
198
+ if (job.kimi_pid !== null)
199
+ pids.add(job.kimi_pid);
200
+ for (const pid of pids) {
201
+ try {
202
+ process.kill(pid, 0);
203
+ return true;
204
+ }
205
+ catch (error) {
206
+ const code = error.code;
207
+ // ESRCH: definitively dead. EPERM: pid likely reused by an
208
+ // unrelated process — treat as dead for our purposes (consistent
209
+ // with isPidAlive in jobs.ts and signalJobProcesses above).
210
+ if (code !== "ESRCH" && code !== "EPERM") {
211
+ return true;
212
+ }
213
+ }
214
+ }
215
+ return false;
216
+ }