kimi-plugin-cc 2.2.2 → 2.4.0

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 (123) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +5 -3
  3. package/.mcp.json +13 -0
  4. package/CHANGELOG.md +18 -0
  5. package/README.md +57 -5
  6. package/agents/kimi-challenge.md +3 -3
  7. package/agents/kimi-pursue.md +1 -1
  8. package/agents/kimi-rescue.md +2 -2
  9. package/agents/kimi-review.md +3 -3
  10. package/commands/README.md +2 -2
  11. package/commands/challenge.md +1 -1
  12. package/commands/pursue.md +1 -1
  13. package/commands/review.md +1 -1
  14. package/dist/background-spawn.js +9 -17
  15. package/dist/cli-client.js +24 -63
  16. package/dist/commands/ask.js +28 -31
  17. package/dist/commands/cancel.js +50 -23
  18. package/dist/commands/cli-helpers.js +7 -16
  19. package/dist/commands/pursue.js +24 -33
  20. package/dist/commands/registry.js +6 -19
  21. package/dist/commands/rescue.js +23 -22
  22. package/dist/commands/review-gate.js +18 -28
  23. package/dist/commands/review.js +41 -45
  24. package/dist/commands/setup.js +85 -171
  25. package/dist/commands/swarm.js +107 -91
  26. package/dist/commands/worker.js +0 -6
  27. package/dist/companion.js +13 -40
  28. package/dist/git.js +14 -7
  29. package/dist/help.js +2 -0
  30. package/dist/hooks/approval-hook.js +9 -20
  31. package/dist/hooks/approval-policy.js +36 -69
  32. package/dist/hooks/config-safety.js +6 -4
  33. package/dist/hooks/install-paths.js +62 -138
  34. package/dist/hooks/install.js +37 -108
  35. package/dist/hooks/managed-block.js +88 -214
  36. package/dist/job-id.js +12 -0
  37. package/dist/job-store.js +65 -28
  38. package/dist/jobs.js +9 -1
  39. package/dist/kimi-command.js +1 -3
  40. package/dist/kimi-engine.js +1 -1
  41. package/dist/kimi-timeouts.js +9 -21
  42. package/dist/kimi-version-probe.js +16 -1210
  43. package/dist/mcp/binding.js +58 -0
  44. package/dist/mcp/job-control.js +345 -0
  45. package/dist/mcp/launcher.js +257 -0
  46. package/dist/mcp/operations.js +301 -0
  47. package/dist/mcp/protocol.js +275 -0
  48. package/dist/mcp/server.js +8 -0
  49. package/dist/mcp/stdio.js +78 -0
  50. package/dist/mcp/supervised-child.js +15 -0
  51. package/dist/mcp/tool-result.js +7 -0
  52. package/dist/mcp/workspace.js +34 -0
  53. package/dist/model-command.js +32 -0
  54. package/dist/native-v2-preflight.js +1 -1
  55. package/dist/parsing.js +4 -18
  56. package/dist/rescue-approval.js +78 -164
  57. package/dist/session-recovery.js +7 -10
  58. package/dist/stream-json.js +6 -174
  59. package/dist/version.js +1 -1
  60. package/docs/migration.md +2 -6
  61. package/docs/registry-releases.md +23 -7
  62. package/docs/safety.md +30 -14
  63. package/package.json +7 -3
  64. package/plugins/kimi-codex/.codex-plugin/plugin.json +8 -5
  65. package/plugins/kimi-codex/.mcp.json +28 -0
  66. package/plugins/kimi-codex/dist/background-spawn.js +9 -17
  67. package/plugins/kimi-codex/dist/cli-client.js +24 -63
  68. package/plugins/kimi-codex/dist/commands/ask.js +28 -31
  69. package/plugins/kimi-codex/dist/commands/cancel.js +50 -23
  70. package/plugins/kimi-codex/dist/commands/cli-helpers.js +7 -16
  71. package/plugins/kimi-codex/dist/commands/pursue.js +24 -33
  72. package/plugins/kimi-codex/dist/commands/registry.js +6 -19
  73. package/plugins/kimi-codex/dist/commands/rescue.js +23 -22
  74. package/plugins/kimi-codex/dist/commands/review-gate.js +18 -28
  75. package/plugins/kimi-codex/dist/commands/review.js +41 -45
  76. package/plugins/kimi-codex/dist/commands/setup.js +85 -171
  77. package/plugins/kimi-codex/dist/commands/swarm.js +107 -91
  78. package/plugins/kimi-codex/dist/commands/worker.js +0 -6
  79. package/plugins/kimi-codex/dist/companion.js +13 -40
  80. package/plugins/kimi-codex/dist/git.js +14 -7
  81. package/plugins/kimi-codex/dist/help.js +2 -0
  82. package/plugins/kimi-codex/dist/hooks/approval-hook.js +9 -20
  83. package/plugins/kimi-codex/dist/hooks/approval-policy.js +36 -69
  84. package/plugins/kimi-codex/dist/hooks/config-safety.js +6 -4
  85. package/plugins/kimi-codex/dist/hooks/install-paths.js +62 -138
  86. package/plugins/kimi-codex/dist/hooks/install.js +37 -108
  87. package/plugins/kimi-codex/dist/hooks/managed-block.js +88 -214
  88. package/plugins/kimi-codex/dist/job-id.js +12 -0
  89. package/plugins/kimi-codex/dist/job-store.js +65 -28
  90. package/plugins/kimi-codex/dist/jobs.js +9 -1
  91. package/plugins/kimi-codex/dist/kimi-command.js +1 -3
  92. package/plugins/kimi-codex/dist/kimi-engine.js +1 -1
  93. package/plugins/kimi-codex/dist/kimi-timeouts.js +9 -21
  94. package/plugins/kimi-codex/dist/kimi-version-probe.js +16 -1210
  95. package/plugins/kimi-codex/dist/mcp/binding.js +58 -0
  96. package/plugins/kimi-codex/dist/mcp/job-control.js +345 -0
  97. package/plugins/kimi-codex/dist/mcp/launcher.js +257 -0
  98. package/plugins/kimi-codex/dist/mcp/operations.js +301 -0
  99. package/plugins/kimi-codex/dist/mcp/protocol.js +275 -0
  100. package/plugins/kimi-codex/dist/mcp/server.js +8 -0
  101. package/plugins/kimi-codex/dist/mcp/stdio.js +78 -0
  102. package/plugins/kimi-codex/dist/mcp/supervised-child.js +15 -0
  103. package/plugins/kimi-codex/dist/mcp/tool-result.js +7 -0
  104. package/plugins/kimi-codex/dist/mcp/workspace.js +34 -0
  105. package/plugins/kimi-codex/dist/model-command.js +32 -0
  106. package/plugins/kimi-codex/dist/native-v2-preflight.js +1 -1
  107. package/plugins/kimi-codex/dist/parsing.js +4 -18
  108. package/plugins/kimi-codex/dist/rescue-approval.js +78 -164
  109. package/plugins/kimi-codex/dist/session-recovery.js +7 -10
  110. package/plugins/kimi-codex/dist/stream-json.js +6 -174
  111. package/plugins/kimi-codex/dist/version.js +1 -1
  112. package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +2 -2
  113. package/plugins/kimi-codex/skills/kimi-cancel/SKILL.md +1 -1
  114. package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +1 -1
  115. package/plugins/kimi-codex/skills/kimi-pursue/SKILL.md +1 -1
  116. package/plugins/kimi-codex/skills/kimi-replay/SKILL.md +1 -1
  117. package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +1 -1
  118. package/plugins/kimi-codex/skills/kimi-result/SKILL.md +1 -1
  119. package/plugins/kimi-codex/skills/kimi-review/SKILL.md +3 -3
  120. package/plugins/kimi-codex/skills/kimi-setup/SKILL.md +1 -1
  121. package/plugins/kimi-codex/skills/kimi-status/SKILL.md +1 -1
  122. package/plugins/kimi-codex/skills/kimi-swarm/SKILL.md +1 -1
  123. package/plugins/kimi-codex/skills/kimi-swarm-write/SKILL.md +1 -1
@@ -1,17 +1,5 @@
1
1
  // Subprocess client for `kimi -p --output-format stream-json`.
2
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
3
  // Per-job env (`KIMI_PLUGIN_CC_CMD`) is set on this spawn so the
16
4
  // PreToolUse hook can branch per command (review vs rescue vs ask). It is
17
5
  // passed via the spawn `env` option, not exported to a shell, so concurrent
@@ -32,12 +20,7 @@ import { collectDescendantIdentities, revalidateProcessIdentities, waitForProces
32
20
  const STDERR_TAIL_BYTES = 8192;
33
21
  /** Hard cap on awaiting diagnostics log drain before resolving the result. */
34
22
  const LOG_DRAIN_TIMEOUT_MS = 250;
35
- /**
36
- * Default delay between SIGTERM and SIGKILL when an abort fires.
37
- * Matches v0.4's `cancellation.ts` escalation timing for wire clients,
38
- * so rescue/ask/review continue to feel identical to a stuck-kimi user
39
- * after the v1.0 cutover.
40
- */
23
+ /** Default 1500 ms delay between SIGTERM and SIGKILL after an abort. */
41
24
  const DEFAULT_ESCALATION_MS = 1_500;
42
25
  /** Maximum time spent confirming POSIX tree exit after SIGKILL dispatch. */
43
26
  const POST_KILL_QUIESCENCE_MS = 500;
@@ -199,11 +182,8 @@ export async function runCliPrompt(opts) {
199
182
  continue;
200
183
  }
201
184
  if (outcome.unknownRecord !== undefined) {
202
- // H3 forward-compat: a stream-json line with a role we don't model
203
- // (a future kimi-code role). Log it for diagnostics but keep it OUT of
204
- // records[] and out of onRecord — consumers iterate records[] expecting
205
- // assistant/tool only. Tolerated, not treated as malformed/error —
206
- // EXCEPT that on a native-v2 plan nothing may precede the marker.
185
+ // Unknown roles are diagnostic-only and excluded from consumer records;
186
+ // native-v2 still refuses any output before its marker.
207
187
  if (requireMarkerBeforeOutput(`an unknown-role (${outcome.unknownRecord.role}) record`)) {
208
188
  return;
209
189
  }
@@ -216,9 +196,7 @@ export async function runCliPrompt(opts) {
216
196
  if (outcome.goalSummary !== undefined) {
217
197
  if (requireMarkerBeforeOutput("goal.summary"))
218
198
  return;
219
- // Goal-mode summary is out-of-band metadata for our wrapper, not a
220
- // consumer-facing record. First-announce wins (a run emits exactly one,
221
- // at session end); capture and skip so records[] stays assistant/tool.
199
+ // Out-of-band metadata; first-announce-wins, excluded from consumer records.
222
200
  if (announcedGoalSummary === undefined) {
223
201
  announcedGoalSummary = outcome.goalSummary;
224
202
  appendLogLine({
@@ -327,7 +305,7 @@ export async function runCliPrompt(opts) {
327
305
  args,
328
306
  command_label: opts.commandLabel ?? null,
329
307
  });
330
- return await new Promise((resolve, reject) => {
308
+ return await new Promise(async (resolve, reject) => {
331
309
  let aborted = false;
332
310
  let cancellationTeardown;
333
311
  let settlementStarted = false;
@@ -552,21 +530,22 @@ export async function runCliPrompt(opts) {
552
530
  });
553
531
  };
554
532
  opts.signal?.addEventListener("abort", onAbort, { once: true });
555
- // Re-check `aborted` AFTER attaching the listener. `await mkdir` above
556
- // is the first synchronous yield point after the pre-spawn aborted
557
- // check — if abort fired during that await, the signal is already in
558
- // an aborted state by the time we reach addEventListener, which does
559
- // NOT re-fire for already-aborted signals (per the AbortSignal spec).
560
- // Without this re-check the spawned child is orphaned: SIGTERM/SIGKILL
561
- // is never sent and the process keeps holding the SQLite row + model
562
- // tokens. Audit report 28 (Codex H1) tracked this fix.
533
+ // Abort may occur during any pre-spawn await, and addEventListener does not
534
+ // replay an already-aborted signal. Re-check after attaching the listener so
535
+ // the spawned child cannot escape cancellation teardown.
563
536
  if (opts.signal?.aborted === true) {
564
537
  onAbort();
565
538
  }
566
- // Swap the pre-attached early listener for the real handler. These two
567
- // statements are synchronous and execute as one unit; no event can
568
- // interleave between marking earlyErrorHandled and attaching the new
569
- // listener.
539
+ if (child.pid !== undefined && opts.onSpawn) {
540
+ try {
541
+ await opts.onSpawn(child.pid);
542
+ }
543
+ catch (error) {
544
+ onAbort();
545
+ settle("reject", new RuntimeError("CLI_SPAWN_CALLBACK_FAILED", `Failed to record the spawned kimi process: ${formatError(error)}`, "cli-client.spawn", error instanceof Error ? { cause: error, details: errorDetails() } : { details: errorDetails() }));
546
+ return;
547
+ }
548
+ }
570
549
  earlyErrorHandled = true;
571
550
  child.on("error", (err) => {
572
551
  appendLogLine({ event: "process_error", message: formatError(err) });
@@ -614,7 +593,7 @@ export async function runCliPrompt(opts) {
614
593
  await raceWithTimeout(logChain, LOG_DRAIN_TIMEOUT_MS);
615
594
  settle("resolve", {
616
595
  spawnedAt,
617
- settledAt: 0, // Filled at settlement, after the teardown barrier.
596
+ settledAt: 0,
618
597
  sessionId,
619
598
  goalSummary: announcedGoalSummary,
620
599
  cliError: announcedCliError,
@@ -722,14 +701,6 @@ export class ResponseTimeoutError extends RuntimeError {
722
701
  *
723
702
  * The generic `withTimeout` (runtime/kimi-timeouts.ts) only rejects
724
703
  * the outer Promise — it has no handle on the kimi child process.
725
- * review/ask/review-gate previously wrapped `runCliPrompt` with
726
- * `withTimeout` and let the orphaned subprocess keep running until
727
- * it crashed or finished on its own. The reviewers (reports 17 +
728
- * 18) flagged this as critical for review_gate in particular: the
729
- * 8 s budget fires inside Claude Code's Stop hook, the parent
730
- * returns "allow stop", and a runaway kimi keeps holding the
731
- * SQLite row + model tokens. Subsequent `/kimi:cancel` can't reach
732
- * it because `kimi_pid` is null.
733
704
  *
734
705
  * Behavior:
735
706
  *
@@ -811,11 +782,6 @@ function buildArgs(opts) {
811
782
  args.push("--skills-dir", dir);
812
783
  }
813
784
  }
814
- // NOTE: thinking effort is requested through the env binding, never CLI argv.
815
- // Non-trait-driven providers or a base level already off ignore it silently;
816
- // an alwaysThinking model receives off unnormalized. stream-json carries no
817
- // thinking deltas (writeThinkingDelta() {} in the JSON writer), so the plugin
818
- // cannot observe whether the request was honoured.
819
785
  // -p accepts the prompt as the option value. We pass it as a separate argv
820
786
  // entry so prompts with leading dashes or whitespace are not interpreted as
821
787
  // additional flags.
@@ -826,15 +792,12 @@ function buildEnv(opts) {
826
792
  const env = { ...opts.env };
827
793
  if (opts.thinkingEffort !== undefined)
828
794
  env.KIMI_MODEL_THINKING_EFFORT = opts.thinkingEffort;
829
- // 2.2.0 audit: ambient trust starts project MCP stdio servers outside PreToolUse.
795
+ // Ambient workspace trust starts project MCP stdio servers outside PreToolUse.
830
796
  delete env.KIMI_CODE_TRUST_WORKSPACE;
831
- // kimi-code 0.33.0 inverted its CLI engine default: an unflagged `kimi -p`
832
- // now selects native agent-core-v2, while this upstream-documented flag pins
833
- // the legacy-v1 path. Native v2 still has a final-allow plan-file path that
834
- // can skip our later external PreToolUse hook, so the engine choice must be a
835
- // plugin-owned spawn invariant rather than ambient operator state. Older
836
- // kimi-code releases ignore the unknown flag. Always overwrite ambient false
837
- // values; accepting one would silently reopen v2 on 0.33+.
797
+ // Legacy-v1 plans use this flag on releases whose unflagged -p default can
798
+ // route to native-v2. Always overwrite ambient false values; older releases
799
+ // ignore it. The engine choice stays plugin-owned, while certified native-v2
800
+ // plans use the no-plan construction rather than a legacy pin.
838
801
  if (opts.executionPlan.intendedEngine === "legacy-v1") {
839
802
  env[KIMI_LEGACY_ENV] = KIMI_LEGACY_FORCED_VALUE;
840
803
  }
@@ -850,8 +813,6 @@ function buildEnv(opts) {
850
813
  env.KIMI_PLUGIN_CC_CMD = opts.commandLabel;
851
814
  }
852
815
  if (opts.swarmMaxConcurrency !== undefined) {
853
- // kimi-code 0.18.0+ caps AgentSwarm's normal-phase concurrency at this
854
- // value; older binaries ignore it. See the field doc on CliClientOptions.
855
816
  env.KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY = String(opts.swarmMaxConcurrency);
856
817
  }
857
818
  if (opts.trustedWorkspaceRoot !== undefined) {
@@ -1,5 +1,5 @@
1
1
  import { companionHelp } from "../help.js";
2
- import { randomUUID } from "node:crypto";
2
+ import { allocateJobId } from "../job-id.js";
3
3
  import path from "node:path";
4
4
  import { createCliCancellationHandlers } from "../cli-cancellation.js";
5
5
  import { runCliPromptWithBudget } from "../cli-client.js";
@@ -19,32 +19,28 @@ import { startBackgroundJob } from "../background-spawn.js";
19
19
  import { hookRefusalDetails, hookRefusalRetryProtocol, maybeWarnHookMissing, verifyHookInstalled, } from "../hooks/install.js";
20
20
  import { warnInheritedBudget, captureTimeoutPartialOutput, assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing, warnHookEscapes } from "./cli-helpers.js";
21
21
  import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js";
22
- // v1.0 cutover note (PR 2):
23
- //
24
- // The v0.4 wire client/initialize/prompt sequence is replaced with a
25
- // single `runCliPrompt` against `kimi -p --output-format stream-json`.
26
- // The PreToolUse hook reads `KIMI_PLUGIN_CC_CMD=ask` and applies the
27
- // same read-only tool policy as review/challenge/review_gate.
28
- //
29
- // Session id handling differs from v0.4:
30
- //
31
- // - v0.4 the client assigned the kimi session id upfront (passed via
32
- // `--session <uuid>` on the wire command line).
33
- // - v1.0 kimi-code assigns the session id and announces it via stderr
34
- // ("To resume this session: kimi -r <uuid>"). We capture that id
35
- // after the call and update the JobStore row.
36
- // - For `--resume`, we pass the prior session id via `-r`; kimi-code
37
- // keeps it across resumes.
38
22
  const ASK_SUMMARY_MAX = 120;
39
23
  const ASK_AGENT_PROFILE_PLACEHOLDER = "<cli-client>";
40
- const ASK_PROMPT_PREFIX = [
41
- "Answer the user's question directly in free-form prose.",
42
- "Stay read-only.",
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.",
43
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,
44
34
  "",
45
35
  "User question:",
46
36
  "",
47
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");
48
44
  export async function runAsk(argv, context) {
49
45
  const parsed = parseAskArgs(argv);
50
46
  if (parsed.help)
@@ -59,7 +55,7 @@ export async function runAsk(argv, context) {
59
55
  const store = new JobStore(paths);
60
56
  try {
61
57
  await sweepStaleJobs(store, paths);
62
- const jobId = randomUUID();
58
+ const jobId = allocateJobId(context);
63
59
  const sessionResolution = await resolveAskSession(store, repoIdentity.repoId, parsed.fresh, parsed.resume, parsed.resumeTarget);
64
60
  const askPrompt = buildAskPrompt(parsed.prompt, sessionResolution.reusedSession);
65
61
  const executionPlan = await prepareKimiExecutionPlan({
@@ -91,7 +87,7 @@ export async function runAsk(argv, context) {
91
87
  ...persistedExecutionPlanFields(executionPlan),
92
88
  agent_profile: ASK_AGENT_PROFILE_PLACEHOLDER,
93
89
  prompt_digest: digestPrompt(askPrompt),
94
- summary: shorten(parsed.prompt ?? askPrompt, ASK_SUMMARY_MAX),
90
+ summary: shorten(askPromptSubject(askPrompt), ASK_SUMMARY_MAX),
95
91
  phase: parsed.background ? "queued" : "starting",
96
92
  final_output_path: null,
97
93
  stream_log_path: logPath,
@@ -156,6 +152,7 @@ export async function executeAskJob(jobId, prompt, context, options) {
156
152
  }
157
153
  handlers = createCliCancellationHandlers();
158
154
  const executionPlan = executionPlanFromPersisted(job);
155
+ store.updateRunningJob(job.job_id, { phase: "turn-running" });
159
156
  // Re-validate resume lineage from the store before spawning: a detached
160
157
  // worker (or a forged queued row) reloads the persisted plan and would
161
158
  // otherwise hand kimi_session_id to the spawn without the dispatch-time
@@ -178,6 +175,7 @@ export async function executeAskJob(jobId, prompt, context, options) {
178
175
  thinkingEffort: job.thinking_effort ?? undefined,
179
176
  resumeSessionId: job.kimi_session_id ?? undefined,
180
177
  logPath: job.stream_log_path,
178
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
181
179
  signal: handlers.signal,
182
180
  }, job.budget_ms ?? KIMI_ASK_PROMPT_TIMEOUT_MS, "ask.prompt");
183
181
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
@@ -192,9 +190,7 @@ export async function executeAskJob(jobId, prompt, context, options) {
192
190
  // kimi-code mints a new id on each turn.
193
191
  if (result.sessionId !== undefined &&
194
192
  result.sessionId.length > 0) {
195
- // length>0 guard: empty-string capture would poison the row. The
196
- // warning helper's null-ish check would also fire but only after
197
- // the bad write. (Kimi alpha.4 challenge finding #3.)
193
+ // Never persist an empty ID because NULL represents a missing session ID.
198
194
  store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
199
195
  }
200
196
  await syncKimiSessionTitle({
@@ -202,7 +198,7 @@ export async function executeAskJob(jobId, prompt, context, options) {
202
198
  env: context.env,
203
199
  cwd: job.cwd,
204
200
  sessionId: result.sessionId ?? job.kimi_session_id,
205
- title: buildKimiSessionTitle("ask", prompt.startsWith(ASK_PROMPT_PREFIX) ? prompt.slice(ASK_PROMPT_PREFIX.length) : prompt),
201
+ title: buildKimiSessionTitle("ask", askPromptSubject(prompt)),
206
202
  stderr: context.stderr,
207
203
  });
208
204
  // For ask, missing session id breaks both -r (latest-resume) and
@@ -322,14 +318,15 @@ function buildAskPrompt(question, reusedSession) {
322
318
  return ASK_PROMPT_PREFIX + question.trim();
323
319
  }
324
320
  if (reusedSession) {
325
- return [
326
- "Continue the previous ask conversation in free-form prose.",
327
- "Stay read-only.",
328
- "Do not emit JSON unless the user explicitly asks for it.",
329
- ].join("\n");
321
+ return ASK_RESUME_PROMPT;
330
322
  }
331
323
  throw new RuntimeError("INVALID_ARGS", "ask requires a question after the flags.", "ask.parse");
332
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
+ }
333
330
  function shorten(text, max) {
334
331
  const normalized = text.replace(/\s+/g, " ").trim();
335
332
  if (normalized.length <= max) {
@@ -5,6 +5,7 @@ import { markJobCancelled, sweepStaleJobs } from "../jobs.js";
5
5
  import { JobStore, withJobStore } from "../job-store.js";
6
6
  import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
7
7
  import { parseJobLookupArgs } from "../parsing.js";
8
+ import { revalidateProcessIdentities } from "../process-tree.js";
8
9
  import { getManagedCommandConfig } from "./registry.js";
9
10
  export async function runCancel(argv, context) {
10
11
  const parsed = parseJobLookupArgs(argv);
@@ -31,30 +32,22 @@ export async function runCancel(argv, context) {
31
32
  message: "cancel is a no-op for terminal jobs.",
32
33
  }, null, 2)}\n`;
33
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
+ }
34
39
  if (job.pid === null && job.kimi_pid === null) {
35
40
  throw new RuntimeError("CANCEL_NOT_SUPPORTED", `Job ${job.job_id} does not have a recorded process id to cancel.`, "cancel.runtime");
36
41
  }
37
- // review_gate runs in a Stop hook; SIGTERMing kimi_pid causes the wire
38
- // process to exit with WIRE_PROCESS_EXITED, which the hook's catch path
39
- // would persist as `failed`. Pre-mark the row as `cancelled` here so the
40
- // user's intent is recorded canonically — the subsequent wire exit is a
41
- // no-op against an already-terminal row (markFailed has a WHERE
42
- // status='running' guard).
42
+ // review_gate runs in a Stop hook. Pre-mark the row as cancelled before
43
+ // signaling so the subprocess close/result path cannot persist a failure
44
+ // over the user's intent; later updates are no-ops on a terminal row.
43
45
  //
44
- // Because the row is now terminal, waitForCancellation returns
45
- // immediately on the status check and never escalates to SIGKILL. Run
46
- // an unconditional SIGTERM→1s→SIGKILL sequence on the recorded pids
47
- // here and return early so the lower-level wait/escalation path does
48
- // not skip cleanup. (v0.2.4)
46
+ // The terminal row also makes waitForCancellation return before its own
47
+ // escalation, so this path always attempts SIGTERM after the pre-mark
48
+ // attempt, waits 1s, and sends SIGKILL only if a recorded process remains
49
+ // live. A pre-mark failure is rethrown only after termination is attempted.
49
50
  if (job.command_type === "review_gate") {
50
- // Pre-mark before signaling so the row persists as `cancelled` rather
51
- // than the `failed` the hook's catch path would write on WIRE_PROCESS_EXITED.
52
- //
53
- // If the pre-mark itself fails (disk I/O, SQLite contention), we must
54
- // STILL signal the Kimi child — otherwise the user's cancel command
55
- // produces an alive-but-marked-cancelled stuck state. Capture the
56
- // pre-mark failure, run the SIGTERM→SIGKILL escalation unconditionally,
57
- // and rethrow at the end so the user sees the underlying error.
58
51
  let preMarkError;
59
52
  try {
60
53
  await withJobStore(paths, async (preMarkStore) => {
@@ -65,6 +58,7 @@ export async function runCancel(argv, context) {
65
58
  catch (error) {
66
59
  preMarkError = error;
67
60
  }
61
+ await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity);
68
62
  signalJobProcesses(job, "SIGTERM");
69
63
  await sleep(1_000);
70
64
  if (hasLiveRecordedProcess(job)) {
@@ -79,6 +73,8 @@ export async function runCancel(argv, context) {
79
73
  message: "review_gate cancelled; SIGTERM→SIGKILL escalation completed.",
80
74
  }, null, 2)}\n`;
81
75
  }
76
+ await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity);
77
+ await assertMcpKimiIdentity(job, context.mcpKimiIdentity);
82
78
  signalJobProcesses(job, "SIGTERM");
83
79
  const cancelled = await waitForCancellation(paths, job.job_id);
84
80
  if (cancelled) {
@@ -88,7 +84,11 @@ export async function runCancel(argv, context) {
88
84
  message: "Cancellation completed.",
89
85
  }, null, 2)}\n`;
90
86
  }
91
- signalJobProcesses(job, "SIGKILL");
87
+ const companionMatches = await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity, true);
88
+ const kimiMatches = await assertMcpKimiIdentity(job, context.mcpKimiIdentity, true);
89
+ if (companionMatches || kimiMatches) {
90
+ signalJobProcesses(job, "SIGKILL", { companion: companionMatches, kimi: kimiMatches });
91
+ }
92
92
  await withJobStore(paths, async (forcedStore) => {
93
93
  const current = forcedStore.getJob(job.job_id);
94
94
  if (current?.status === "running") {
@@ -105,11 +105,38 @@ export async function runCancel(argv, context) {
105
105
  store.close();
106
106
  }
107
107
  }
108
- function signalJobProcesses(job, signal) {
108
+ async function assertMcpCompanionIdentity(job, expected, allowMissing = false) {
109
+ return assertMcpProcessIdentity(job.pid, expected, "companion", allowMissing);
110
+ }
111
+ async function assertMcpKimiIdentity(job, expected, allowMissing = false) {
112
+ return assertMcpProcessIdentity(job.kimi_pid, expected, "Kimi", allowMissing);
113
+ }
114
+ async function assertMcpProcessIdentity(actualPid, expected, label, allowMissing) {
115
+ if (expected === undefined)
116
+ return true;
117
+ if (expected === null) {
118
+ if (actualPid === null)
119
+ return false;
120
+ throw new RuntimeError("CANCEL_NOT_SUPPORTED", `MCP job ${actualPid} has no verified ${label} identity; refusing PID-only cancellation.`, "mcp.cancel");
121
+ }
122
+ if (actualPid !== expected.pid) {
123
+ throw new RuntimeError("MCP_PROCESS_IDENTITY_MISMATCH", `The recorded ${label} process identity no longer matches; no signal was sent.`, "mcp.cancel");
124
+ }
125
+ const validation = await revalidateProcessIdentities([expected]);
126
+ if (!validation.complete) {
127
+ 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");
128
+ }
129
+ const matches = validation.identities.length > 0;
130
+ if (!matches && !allowMissing) {
131
+ throw new RuntimeError("MCP_PROCESS_IDENTITY_MISMATCH", `The recorded ${label} process identity no longer matches; no signal was sent.`, "mcp.cancel");
132
+ }
133
+ return matches;
134
+ }
135
+ function signalJobProcesses(job, signal, include = { companion: true, kimi: true }) {
109
136
  const pids = new Set();
110
- if (job.pid !== null)
137
+ if (include.companion && job.pid !== null)
111
138
  pids.add(job.pid);
112
- if (job.kimi_pid !== null)
139
+ if (include.kimi && job.kimi_pid !== null)
113
140
  pids.add(job.kimi_pid);
114
141
  for (const pid of pids) {
115
142
  try {
@@ -44,29 +44,20 @@ export function reassembleProseFromRecords(records) {
44
44
  return out;
45
45
  }
46
46
  /**
47
- * Loud-fail warning when kimi finished a job but never announced a
48
- * session id on stderr. We persist `kimi_session_id` as NULL in that
49
- * case (the row is unresumable), and the user is silently surprised
50
- * later when `-r` or `/kimi:replay` returns nothing.
47
+ * Warn when kimi finishes without announcing a session ID. The job keeps
48
+ * `kimi_session_id` NULL, so resume is unavailable.
51
49
  *
52
- * The right surface is human-readable stderr per the LLM-caller
53
- * discipline invariant — load-bearing context still goes via the SQLite
54
- * row (`kimi_session_id IS NULL`), but the user sees this warning
55
- * inline immediately after the prompt completes so they know resume is
56
- * unavailable for this job.
57
- *
58
- * Called at the end of every command that captures sessionId (review,
59
- * challenge, ask, rescue). Review-gate intentionally skips: it's a Stop
60
- * hook with no resume semantics and we don't want a runtime warning to
61
- * leak into Claude Code's transcript on every assistant turn.
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.
62
53
  */
63
54
  export function warnIfSessionIdMissing(result, commandLabel, jobId, stderr) {
64
55
  if (result.sessionId !== undefined && result.sessionId.length > 0) {
65
56
  return;
66
57
  }
67
58
  stderr.write(`[${commandLabel}] kimi did not announce a session id for job ${jobId}. ` +
68
- `Resume and replay will not work for this job. ` +
69
- `If this happens repeatedly, check kimi-code's stderr format (the " To resume this session" line) and report upstream.\n`);
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`);
70
61
  }
71
62
  /**
72
63
  * Translate a cli-client `CliClientResult` into the kind of error
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { allocateJobId } from "../job-id.js";
2
2
  import path from "node:path";
3
3
  import { createCliCancellationHandlers } from "../cli-cancellation.js";
4
4
  import { runCliPromptWithBudget } from "../cli-client.js";
@@ -16,36 +16,13 @@ import { readArtifact, renderManagedJobOutput, writeArtifact } from "../render.j
16
16
  import { hookRefusalDetails, hookRefusalRetryProtocol, maybeWarnHookMissing, verifyHookInstalled, } from "../hooks/install.js";
17
17
  import { assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing, warnHookEscapes } from "./cli-helpers.js";
18
18
  import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js";
19
- // /kimi:pursue — autonomous goal mode (kimi-code 0.8.0+ headless `/goal`).
20
- //
21
- // PROTOTYPE SCOPE (v1.1 experimental). Deliberately narrow:
22
- // - FOREGROUND ONLY. No --background/--detach yet. Goal mode is experimental
23
- // upstream and its cancellation semantics want a human watching; a hard
24
- // wall-clock budget bounds the run. (kimi's own shape-consult, report 60.)
25
- // - NO --resume. In goal mode `goal.summary` emits a *goalId* that is
26
- // DISTINCT from the resume-hint *sessionId*; `kimi -r <sessionId>` re-enters
27
- // the session but not necessarily the goal continuation. Exposing resume
28
- // before that split is reconciled upstream would be a silent-failure trap,
29
- // so we capture+surface the goalId but don't offer resume yet.
30
- // - Reuses the RESCUE job lineage (command_type "rescue", KIMI_PLUGIN_CC_CMD=
31
- // "rescue") so the PreToolUse hook applies the workspace write allowlist to
32
- // EVERY continuation turn, and /kimi:status / /kimi:result / /kimi:cancel
33
- // work unchanged. Promoting pursue to a first-class command_type is a
34
- // follow-up (would ripple through ManagedCommandType + the registry).
35
- //
36
- // SAFETY: the hook fires on every tool call in every continuation turn (policy
37
- // index 0, verified against kimi-code 0.9.0 — report 58 — and re-verified through
38
- // 0.12.0 by the goal-mode real-binary smoke, reports 61-65). So a goal-mode run
39
- // is exactly as write-gated as a single-turn rescue. The only NEW risk is
40
- // unboundedness, bounded here by the AbortController wall-clock ceiling.
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.
41
24
  const PURSUE_SUMMARY_MAX = 120;
42
25
  const PURSUE_AGENT_PROFILE = "<goal-mode>";
43
- /**
44
- * Map a headless goal-mode exit code to a terminal status. complete=0 is
45
- * success; blocked=3 and paused=6 are terminal-but-resumable (NOT failures);
46
- * anything else is a genuine process failure. Source:
47
- * apps/kimi-code/src/cli/goal-prompt.ts::GOAL_EXIT_CODES.
48
- */
49
26
  /**
50
27
  * Goal-terminal exits (3 = blocked, 6 = paused) are NOT failures, so
51
28
  * `assertCliResultSuccess` never runs for them — which means a native-v2 plan
@@ -69,6 +46,12 @@ export function assertGoalRunProvenance(plan, result) {
69
46
  },
70
47
  });
71
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
+ */
72
55
  export function classifyGoalExit(exitCode) {
73
56
  switch (exitCode) {
74
57
  case 0:
@@ -88,9 +71,16 @@ export function classifyGoalExit(exitCode) {
88
71
  * wall-clock AbortController; goal budget mutation is not authorized.
89
72
  */
90
73
  export function buildGoalPrompt(objective, turns) {
91
- const trimmed = objective.trim();
92
- const turnHint = turns !== undefined ? ` Stop after at most ${turns} turns; this is a soft instruction, not an enforced turn budget.` : "";
93
- return `/goal ${trimmed}${turnHint}`;
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}`;
94
84
  }
95
85
  function renderGoalHeader(summary, exitStatus) {
96
86
  const status = summary?.status ?? exitStatus;
@@ -132,7 +122,7 @@ export async function runPursue(argv, context) {
132
122
  env: context.env,
133
123
  });
134
124
  const prompt = buildGoalPrompt(objective, parsed.turns);
135
- const jobId = randomUUID();
125
+ const jobId = allocateJobId(context);
136
126
  const logPath = path.join(paths.logsDir, `pursue-${jobId}.jsonl`);
137
127
  const job = store.createJob({
138
128
  job_id: jobId,
@@ -236,6 +226,7 @@ async function executePursueJob(jobId, prompt, objective, budgetMs, context) {
236
226
  model: job.model ?? undefined,
237
227
  thinkingEffort: job.thinking_effort ?? undefined,
238
228
  logPath: job.stream_log_path,
229
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
239
230
  signal: handlers.signal,
240
231
  }, budgetMs, "pursue.prompt");
241
232
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
@@ -3,11 +3,8 @@ const passthroughOutput = "passthrough";
3
3
  const parsedOutput = "parsed";
4
4
  const DEFAULT_ESCALATION_MS = 1_500;
5
5
  /**
6
- * Build the cancellation config from a short prefix. The prefix is
7
- * both the title-cased noun ("Ask"/"Rescue"/...) and the SCREAMING
8
- * uppercase error-code stem ("ASK_"/"RESCUE_"/...). Centralizes the
9
- * pattern that ask/rescue/review used to inline in three near-identical
10
- * variants.
6
+ * Build cancellation config from the display name used verbatim in messages
7
+ * and the uppercase stem used in error codes.
11
8
  */
12
9
  function makeCancellationConfig(prefix) {
13
10
  const { displayName, errorCodeStem } = prefix;
@@ -28,21 +25,11 @@ function makeCancellationConfig(prefix) {
28
25
  };
29
26
  }
30
27
  /**
31
- * Single source of truth for per-command behavior. Keyed by
32
- * `ManagedCommandType` — only commands that actually run the
33
- * managed-cancellation flow participate. `setup` and `task` are
34
- * intentionally NOT here: they don't go through the SIGTERM/cancel
35
- * path that this registry configures, so adding placeholder entries
36
- * for them would be type-system noise without behavior.
28
+ * Single source of truth for per-command behavior. Only commands using the
29
+ * managed-cancellation flow participate; `setup` and `task` do not.
37
30
  *
38
- * Casing note: `ask`, `rescue`, and `review_gate` carry capitalized
39
- * `displayName` values matching their pre-registry hardcoded strings
40
- * ("Ask cancelled...", "Rescue failed.", "review_gate cancelled..."
41
- * for the lowercased compound noun). `review` and `challenge` carry
42
- * lowercase displayName values to match their pre-registry pattern
43
- * (the old code interpolated `${commandType}` directly). Mixed casing
44
- * is established prior art — the registry preserves it rather than
45
- * homogenizing.
31
+ * Display-name casing is part of the user-visible contract: `Ask` and `Rescue`
32
+ * are title-cased, while `review`, `challenge`, and `review_gate` are lowercase.
46
33
  */
47
34
  export const COMMAND_REGISTRY = {
48
35
  ask: {