kimi-plugin-cc 2.0.8 → 2.2.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 (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +5 -5
  5. package/agents/kimi-ask.md +1 -1
  6. package/agents/kimi-challenge.md +1 -1
  7. package/agents/kimi-rescue.md +1 -1
  8. package/agents/kimi-review.md +1 -1
  9. package/commands/ask.md +1 -1
  10. package/commands/challenge.md +1 -1
  11. package/commands/rescue.md +1 -1
  12. package/commands/review.md +1 -1
  13. package/dist/cli-client.js +9 -3
  14. package/dist/commands/ask.js +10 -6
  15. package/dist/commands/cli-helpers.js +53 -4
  16. package/dist/commands/pursue.js +2 -1
  17. package/dist/commands/rescue.js +12 -9
  18. package/dist/commands/result.js +3 -1
  19. package/dist/commands/review-gate.js +2 -1
  20. package/dist/commands/review.js +4 -3
  21. package/dist/commands/status.js +2 -2
  22. package/dist/commands/swarm.js +3 -1
  23. package/dist/hooks/config-safety.js +15 -7
  24. package/dist/job-store.js +36 -4
  25. package/dist/session-recovery.js +203 -0
  26. package/dist/vendor/smol-toml/date.js +50 -21
  27. package/dist/vendor/smol-toml/error.js +6 -2
  28. package/dist/vendor/smol-toml/extract.js +259 -66
  29. package/dist/vendor/smol-toml/parse.js +64 -48
  30. package/dist/vendor/smol-toml/primitive.js +107 -135
  31. package/dist/vendor/smol-toml/struct.js +106 -134
  32. package/dist/vendor/smol-toml/util.js +22 -78
  33. package/dist/version.js +1 -1
  34. package/docs/registry-releases.md +22 -1
  35. package/docs/safety.md +49 -2
  36. package/package.json +1 -1
  37. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  38. package/plugins/kimi-codex/dist/cli-client.js +9 -3
  39. package/plugins/kimi-codex/dist/commands/ask.js +10 -6
  40. package/plugins/kimi-codex/dist/commands/cli-helpers.js +53 -4
  41. package/plugins/kimi-codex/dist/commands/pursue.js +2 -1
  42. package/plugins/kimi-codex/dist/commands/rescue.js +12 -9
  43. package/plugins/kimi-codex/dist/commands/result.js +3 -1
  44. package/plugins/kimi-codex/dist/commands/review-gate.js +2 -1
  45. package/plugins/kimi-codex/dist/commands/review.js +4 -3
  46. package/plugins/kimi-codex/dist/commands/status.js +2 -2
  47. package/plugins/kimi-codex/dist/commands/swarm.js +3 -1
  48. package/plugins/kimi-codex/dist/hooks/config-safety.js +15 -7
  49. package/plugins/kimi-codex/dist/job-store.js +36 -4
  50. package/plugins/kimi-codex/dist/session-recovery.js +203 -0
  51. package/plugins/kimi-codex/dist/vendor/smol-toml/date.js +50 -21
  52. package/plugins/kimi-codex/dist/vendor/smol-toml/error.js +6 -2
  53. package/plugins/kimi-codex/dist/vendor/smol-toml/extract.js +259 -66
  54. package/plugins/kimi-codex/dist/vendor/smol-toml/parse.js +64 -48
  55. package/plugins/kimi-codex/dist/vendor/smol-toml/primitive.js +107 -135
  56. package/plugins/kimi-codex/dist/vendor/smol-toml/struct.js +106 -134
  57. package/plugins/kimi-codex/dist/vendor/smol-toml/util.js +22 -78
  58. package/plugins/kimi-codex/dist/version.js +1 -1
  59. package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +1 -1
  60. package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +1 -1
  61. package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +1 -1
  62. package/plugins/kimi-codex/skills/kimi-review/SKILL.md +1 -1
@@ -6,7 +6,9 @@ remain available. The package has no install scripts and no separate CLI.
6
6
 
7
7
  ## Publishing status
8
8
 
9
- As of September 26, 2026, [2.0.7 is live on npm](https://www.npmjs.com/package/kimi-plugin-cc/v/2.0.7).
9
+ As of October 15, 2026, [2.0.8 is live on npm](https://www.npmjs.com/package/kimi-plugin-cc/v/2.0.8), published through
10
+ GitHub Actions OIDC with a signed provenance statement (run 38650044033). 2.0.7 (September 26) was the
11
+ hand-published bootstrap.
10
12
  The first upload used the validated GitHub Actions artifact and interactive npm
11
13
  authentication. Its public tarball matched that artifact byte-for-byte, and a
12
14
  fresh registry install passed isolated setup/check for both hosts.
@@ -17,6 +19,25 @@ The trusted publisher is configured for `linxule/kimi-plugin-cc`, workflow
17
19
  use short-lived OIDC credentials, without a stored npm token or interactive 2FA.
18
20
  The first npm OIDC publication will be the next release; do not republish 2.0.7.
19
21
 
22
+ **2026-10-15 status.** The `v2.0.8` release triggered the first OIDC attempt. It
23
+ failed twice on the publish step for reasons unrelated to the package: current
24
+ npm parses a bare `package/<file>.tgz` argument as the GitHub shorthand
25
+ `github:package/<file>` (fixed in `publish.yml`, which now locates the tarball
26
+ and publishes an anchored `./` path), and the manual re-dispatch then received
27
+ `404 Not Found - PUT https://registry.npmjs.org/kimi-plugin-cc`, the response
28
+ npm gives when the OIDC token exchange is refused. The GitHub Actions side
29
+ checks out (environment `npm` exists with no protection rules;
30
+ `NPM_TRUSTED_PUBLISHING=true`), so the cause was on npmjs.com: the trusted-publisher binding created on
31
+ September 26 had **expired unused** (npm expires a binding that never publishes).
32
+ Deleting it and creating a new one (GitHub Actions, `linxule` / `kimi-plugin-cc` /
33
+ `publish.yml` / environment `npm`, with *Allow npm publish* ticked) and
34
+ dispatching `publish.yml` with `tag=v2.0.8` published 2.0.8 with provenance. A new
35
+ binding shows *Pending validation* until its first publish, which must happen
36
+ within about a day; publish promptly after creating one, and expect to recreate it
37
+ after a long gap between releases. Note that a `release` event runs the workflow
38
+ file at the tag, so fixes to `publish.yml` reach an existing tag only through
39
+ `workflow_dispatch`.
40
+
20
41
  ## Validation
21
42
 
22
43
  Use the repository's normal version bump and generated-surface procedure in
package/docs/safety.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Safety
2
2
 
3
- This guide describes the shipped 2.0.6 runtime. Read-only tasks deny file writes and shell commands. Rescue and pursue can edit your workspace. Write-swarm returns a patch from a temporary Git worktree.
3
+ This guide describes the shipped 2.2.0 runtime. Read-only tasks deny file writes and shell commands. Rescue and pursue can edit your workspace. Write-swarm returns a patch from a temporary Git worktree.
4
4
 
5
5
  Native agent-core-v2 is certified at exact CLI versions `0.42.0`, `0.43.0`, `0.43.1`, `2.0.0`, `2.0.1`, `2.0.2`, `2.1.0`, and `2.1.1`, with plan mode disabled. The [runtime certification table](../runtime/kimi-engine.ts) controls routing. The hook is not an operating-system sandbox.
6
6
 
@@ -74,7 +74,7 @@ Key constraints (verified by [`runtime/hooks/managed-block.ts`](../runtime/hooks
74
74
  3. **TOML basic-string escaping.** The command field is a TOML basic string. Quotes, backslashes, newlines, and control characters in the resolved hook path are escaped during install; paths containing characters that cannot be safely escaped (literal `"`, raw control chars) are rejected up front with `SETUP_HOOK_PATH_UNSAFE`.
75
75
  4. **Exact marker grammar.** The verifier rejects stray comments containing the marker tag, partial references, duplicate blocks (from a setup race), and orphan markers (from a manual edit). The same parser is used by both the installer and the per-call verifier so the two cannot disagree.
76
76
  5. **Exact command equality, per host.** The verifier reconstructs the canonical command byte string from the current env and equality-checks the installed `command` (never a substring — a crafted `true # /path/to/approval-hook.js` runs only `true` and would auto-allow). This holds *per host id* (below): each host verifies its own block.
77
- 6. **Full-config parse and whole-hook-array validation.** The verifier parses the complete file with vendored `smol-toml@1.6.1` with the upstream GHSA-7w5x-hrqm-74c2 fix backported (see `runtime/vendor/smol-toml/README.md`), before inspecting the managed block. It then applies upstream's strict config schema to every configured hook (`event`, optional string `matcher`, non-empty `command`, optional integer `timeout` 1–600, no extra fields). A malformed unrelated TOML value or one invalid foreign hook can make kimi-code discard enforcement; both therefore fail verification.
77
+ 6. **Full-config parse and whole-hook-array validation.** The verifier parses the complete file with vendored `smol-toml@1.9.1` with upstream fixes included and no local JavaScript modifications (see `runtime/vendor/smol-toml/README.md`), before inspecting the managed block. It then applies upstream's strict config schema to every configured hook (`event`, optional string `matcher`, non-empty `command`, optional integer `timeout` 1–600, no extra fields). A malformed unrelated TOML value or one invalid foreign hook can make kimi-code discard enforcement; both therefore fail verification.
78
78
  7. **Serialized, identity-bound setup mutations.** Install and uninstall hold a private `0o600` sibling lock across the complete read-modify-write transaction. The candidate lock is published atomically; lock reads are no-follow, nonblocking, regular-file-only, and capped at 4 KiB. Acquisition is bounded, live owners are never stolen, stale recovery is guarded by a nonrecursive recovery lease, and inode/token ownership is rechecked before config mutation or lock removal. Simultaneous Claude Code/Codex setup runs therefore cannot lose either host's block, and a replaced lock is never treated as ours. A newly created Kimi home is `0o700`; atomic config writes remain `0o600`.
79
79
  8. **Inline hook arrays are normalized before insertion.** kimi-code accepts both `hooks = [...]` and `[[hooks]]`, but appending a managed array-of-tables block after an inline assignment can produce a conflicting document. Setup converts a valid inline hook array to canonical `[[hooks]]` tables before inserting the host block and emits an explicit warning that formatting and comments inside that assignment are normalized. Surrounding config bytes are preserved.
80
80
 
@@ -225,6 +225,53 @@ If another installed host still points to an older hook, that hook may veto thes
225
225
  - **Field-proven by a real-binary smoke.** `tests/runtime/real-binary-smoke.test.ts` runs the real `runSwarm --write` and asserts the POSITIVE end-to-end (a `coder` subagent's edits land in the worktree, captured as a patch; the user's real tree is byte-identical + git-clean; the worktree is cleaned up) plus the NEGATIVE (an absolute-path write outside the trusted root is hook-denied). It is the check that catches `tool_input` field-name drift — `Write`/`Edit` carry `path`, not `file_path`, and reading the wrong key denies every real write silently.
226
226
  - **Model-invocable agent (v1.5), patch-only by construction.** Following the field-proof above, `--write` now has a `kimi-swarm-write` subagent so the main Claude thread can dispatch a write fan-out on its own judgement — the same human-command + model-invocable-agent split the rest of the suite uses. Auto-dispatch is a safe extension here because it widens **no** write surface and removes **no** bound: the agent path runs the identical `runSwarm --write` code, so edits still land only in the throwaway worktree, the managed hook still gates every `coder` subagent, the patch is still handed back for the user to apply (**the plugin never applies or commits**), and `--budget` + default `--max-concurrency` 1 + the hook requirement all still hold. In fact write-swarm is a *less* dangerous agent than the `kimi-pursue` agent already shipped: pursue writes directly into the workspace, whereas write-swarm's output is a patch the human reviews before anything reaches a real tree. Because it is still a write surface, the agent description demands BOTH signals — many disjoint write targets AND explicit fan-out intent — and steers single edits to `kimi-rescue`, read-only fan-outs to `kimi-swarm`, and autonomous loops to `kimi-pursue`. Default `--max-concurrency` stays **1**; raising it (concurrent writers) remains a deliberate, human-or-explicitly-requested choice.
227
227
 
228
+ ## Response budgets and partial output (2.2.0)
229
+
230
+ Every single-turn command runs under a wall-clock budget: ask, review and
231
+ challenge default to 30 minutes, rescue to 45, and `--budget <dur>` (at most
232
+ 24h) overrides it per job. The budget is persisted on the job row, so a detached
233
+ worker and a same-operation resume use the same ceiling. For these commands the
234
+ budget is a response-time limit, not a safety bound: a timeout is a safe
235
+ failure. The job stays `failed`; whatever assistant prose had settled is written
236
+ into the artifact under a heading that says it is NOT a final answer, and
237
+ `result <jobId> --json` reports `partial_output: true` when prose exists.
238
+ Cancellation and nonzero exits do not capture partial output. A session ID is kept if Kimi announced it or, on a native-v2 budget timeout, recovered from the session store when exactly one session matches this spawn — created between spawn and settlement in the same directory AND whose journal opens with this job's exact prompt; `result --json` identifies the source in `kimi_session_id_source`, and `error.details.session_recovery` says why no ID was recovered (`none`, `journal_missing:<n>`, `digest_mismatch:<n>`, `ambiguous:<n>`, `enumeration_capped`, `engine_unobserved`). Recovery is bounded and refuses ambiguity, incomplete enumeration, and IDs already used by another job on this host. The prompt binding is what makes attribution proof rather than probability: a concurrent interactive session in the same directory, another host's finished job (host stores cannot see each other's IDs) or a `state.json` planted by repository code running under rescue all fail it unless they carry the exact prompt, and a planted clean journal could otherwise replay attacker-written turns into the next resume. Recovery is skipped when the run never emitted the `system.version` marker, since resume would refuse the row as lineage-unknown anyway. Recovered sessions still require proven native-v2 lineage and the raw plan-taint scan before resume. Inherited budgets are printed to stderr at launch; status and `result --json` expose `budget_source` (`explicit`, `inherited`, or `default`; old non-null budgets report `unknown`).
239
+
240
+ Pursue and swarm keep their ceilings as the sole hard bound on autonomy and
241
+ cost. One nuance: a run whose process exits cleanly in the same event-loop tick
242
+ as the budget timer is accepted as a success rather than discarded, so a goal
243
+ can complete at most one tick past its ceiling — teardown has already settled by
244
+ then, and nothing runs longer.
245
+
246
+ Every spawn also deletes `KIMI_CODE_TRUST_WORKSPACE` from the child
247
+ environment (kimi-code 2.2.0): a trusted workspace boots repo-defined project
248
+ MCP servers at bootstrap, outside the PreToolUse hook, and an inherited value
249
+ would have trusted every plugin spawn including swarm-write's throwaway
250
+ worktree. A directory the operator trusted interactively in the Kimi TUI still
251
+ has that property; that persisted-trust route is a documented residual, not
252
+ something the plugin can close.
253
+
254
+ ## Running a newer Kimi than the plugin certifies
255
+
256
+ The plugin refuses any kimi-code version it has not certified, so an operator
257
+ who tracks `@latest` loses every `/kimi:*` command until the next certification.
258
+ The clean way to have both is to give the plugin its own binary: install the
259
+ highest certified version somewhere stable and point `KIMI_PLUGIN_CC_KIMI_BIN`
260
+ at it (the runtime resolves that path instead of `kimi` on `PATH`; every spawn
261
+ still exports `KIMI_CODE_NO_AUTO_UPDATE=1`, so the pinned copy never drifts).
262
+
263
+ ```sh
264
+ PIN=~/.local/share/kimi-plugin-cc/kimi-bin/2.2.0
265
+ mkdir -p "$PIN" && cd "$PIN" && printf '{"name":"kimi-plugin-cc-pin","private":true}\n' > package.json
266
+ bun add @moonshot-ai/kimi-code@2.2.0
267
+ export KIMI_PLUGIN_CC_KIMI_BIN="$PIN/node_modules/.bin/kimi" # e.g. in ~/.zshenv
268
+ ```
269
+
270
+ Both binaries share `~/.kimi-code` (login, config, hook), so nothing else
271
+ changes. The certified maximum can be read from the installed plugin
272
+ (`NATIVE_V2_CERTIFIED_VERSIONS` in `dist/kimi-engine.js`), which lets an upgrade
273
+ script bump the pin automatically after each certification.
274
+
228
275
  ## The setup probe
229
276
 
230
277
  `/kimi:setup` runs a two-layer probe before reporting success:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kimi-plugin-cc",
3
- "version": "2.0.8",
3
+ "version": "2.2.0",
4
4
  "description": "Kimi for Claude Code and Codex: code questions, reviews, fixes, and parallel tasks through the local Kimi Code CLI.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kimi",
3
- "version": "2.0.8",
3
+ "version": "2.2.0",
4
4
  "description": "Codex plugin that delegates review, challenge, ask, rescue, pursue, and swarm workflows to the local kimi-code CLI through the kimi-plugin-cc companion runtime.",
5
5
  "author": {
6
6
  "name": "linxule"
@@ -84,7 +84,9 @@ export async function runCliPrompt(opts) {
84
84
  });
85
85
  }
86
86
  let child;
87
+ let spawnedAt;
87
88
  try {
89
+ spawnedAt = Date.now();
88
90
  child = spawn(opts.command, args, {
89
91
  cwd: opts.cwd,
90
92
  env,
@@ -329,7 +331,7 @@ export async function runCliPrompt(opts) {
329
331
  return;
330
332
  }
331
333
  if (kind === "resolve")
332
- resolve(payload);
334
+ resolve({ ...payload, settledAt: Date.now() });
333
335
  else
334
336
  reject(payload);
335
337
  });
@@ -580,6 +582,8 @@ export async function runCliPrompt(opts) {
580
582
  // The result itself is in memory; diagnostics are best-effort.
581
583
  await raceWithTimeout(logChain, LOG_DRAIN_TIMEOUT_MS);
582
584
  settle("resolve", {
585
+ spawnedAt,
586
+ settledAt: 0, // Filled at settlement, after the teardown barrier.
583
587
  sessionId,
584
588
  goalSummary: announcedGoalSummary,
585
589
  systemVersion: announcedSystemVersion,
@@ -656,7 +660,9 @@ export function requireSessionId(result, context) {
656
660
  },
657
661
  });
658
662
  }
659
- /** Settled timeout data; stream records are deliberately non-serializable. */
663
+ /** Settled timeout data; stream records are deliberately non-serializable.
664
+ * Without a partial result (teardown rejection), aborted means abort was issued,
665
+ * not that the process was observed aborting. */
660
666
  export class ResponseTimeoutError extends RuntimeError {
661
667
  constructor(budgetMs, stage, commandLabel, partialResult, cause) {
662
668
  super("RESPONSE_TIMEOUT", `${stage} timed out after ${budgetMs}ms.`, stage, {
@@ -666,7 +672,7 @@ export class ResponseTimeoutError extends RuntimeError {
666
672
  command_label: commandLabel ?? null,
667
673
  record_count: partialResult?.records.length ?? 0,
668
674
  partial_output_chars: partialResult?.records.reduce((count, record) => count + (record.role === "assistant" && typeof record.content === "string" ? record.content.length : 0), 0) ?? 0,
669
- aborted: true,
675
+ aborted: partialResult?.aborted ?? true,
670
676
  ...(partialResult ? { settled_post_abort: true } : {}),
671
677
  },
672
678
  });
@@ -17,7 +17,7 @@ import { readArtifact, renderManagedJobOutput, writeArtifact } from "../render.j
17
17
  import { classifyManagedCommandFailure } from "../kimi-errors.js";
18
18
  import { startBackgroundJob } from "../background-spawn.js";
19
19
  import { hookRefusalDetails, hookRefusalRetryProtocol, maybeWarnHookMissing, verifyHookInstalled, } from "../hooks/install.js";
20
- import { captureTimeoutPartialOutput, assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing } from "./cli-helpers.js";
20
+ import { warnInheritedBudget, captureTimeoutPartialOutput, assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing } from "./cli-helpers.js";
21
21
  import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js";
22
22
  // v1.0 cutover note (PR 2):
23
23
  //
@@ -80,10 +80,13 @@ export async function runAsk(argv, context) {
80
80
  thinking: parsed.thinking ?? null,
81
81
  background: parsed.background,
82
82
  budget_ms: parsed.budgetMs ?? (sessionResolution.sourceJob?.operation_kind === "ask" ? sessionResolution.sourceJob.budget_ms : null) ?? null,
83
+ budget_source: parsed.budgetMs !== undefined ? "explicit"
84
+ : sessionResolution.sourceJob?.operation_kind === "ask" && sessionResolution.sourceJob.budget_ms !== null ? "inherited" : "default",
83
85
  pid: parsed.background ? null : process.pid,
84
86
  kimi_pid: null,
85
87
  status: "running",
86
88
  kimi_session_id: sessionResolution.kimiSessionId,
89
+ kimi_session_id_source: sessionResolution.sourceJob?.kimi_session_id_source ?? null,
87
90
  ...persistedExecutionPlanFields(executionPlan),
88
91
  agent_profile: ASK_AGENT_PROFILE_PLACEHOLDER,
89
92
  prompt_digest: digestPrompt(askPrompt),
@@ -105,6 +108,7 @@ export async function runAsk(argv, context) {
105
108
  await markJobFailed(store, paths, job, classified, askConfig.cancellation.failedSummary, { phase: "failed" });
106
109
  throw classified;
107
110
  }
111
+ warnInheritedBudget(job, context.stderr);
108
112
  if (parsed.background) {
109
113
  return startBackgroundJob(job, askPrompt, context, paths, {
110
114
  workerKind: "ask",
@@ -184,12 +188,11 @@ export async function executeAskJob(jobId, prompt, context, options) {
184
188
  // sessions it should match the input, but capture anyway in case
185
189
  // kimi-code mints a new id on each turn.
186
190
  if (result.sessionId !== undefined &&
187
- result.sessionId.length > 0 &&
188
- result.sessionId !== job.kimi_session_id) {
191
+ result.sessionId.length > 0) {
189
192
  // length>0 guard: empty-string capture would poison the row. The
190
193
  // warning helper's null-ish check would also fire but only after
191
194
  // the bad write. (Kimi alpha.4 challenge finding #3.)
192
- store.updateRunningJob(job.job_id, { kimi_session_id: result.sessionId });
195
+ store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
193
196
  }
194
197
  await syncKimiSessionTitle({
195
198
  promptText: prompt,
@@ -219,6 +222,9 @@ export async function executeAskJob(jobId, prompt, context, options) {
219
222
  }) ?? job);
220
223
  }
221
224
  catch (error) {
225
+ const classified = error instanceof RuntimeError && error.stage === "ask.hook-check" ? error
226
+ : classifyManagedCommandFailure(error, "ask", job.job_id, { background: job.background, budgetMs: job.budget_ms ?? KIMI_ASK_PROMPT_TIMEOUT_MS });
227
+ const partialOutput = await captureTimeoutPartialOutput(classified, store, job, context.env, handlers?.signal);
222
228
  if (handlers?.cancelling) {
223
229
  const cancelledError = new RuntimeError(cancel.errorCodes.cancelled, cancel.cancelMessages.default, "ask.runtime", error instanceof Error ? { cause: error } : undefined);
224
230
  return await markJobCancelled(store, paths, job, cancel.cancelledSummary, cancelledError, { phase: "cancelled" });
@@ -231,8 +237,6 @@ export async function executeAskJob(jobId, prompt, context, options) {
231
237
  await markJobFailed(store, paths, job, error, cancel.failedSummary, { phase: "failed" });
232
238
  throw error;
233
239
  }
234
- const classified = classifyManagedCommandFailure(error, "ask", job.job_id, { background: job.background, budgetMs: job.budget_ms ?? KIMI_ASK_PROMPT_TIMEOUT_MS });
235
- const partialOutput = captureTimeoutPartialOutput(classified, store, job);
236
240
  await markJobFailed(store, paths, job, classified, cancel.failedSummary, { phase: "failed", partialOutput });
237
241
  throw classified;
238
242
  }
@@ -1,6 +1,9 @@
1
1
  import { RuntimeError } from "../errors.js";
2
2
  import { ResponseTimeoutError } from "../cli-client.js";
3
3
  import { observedExecutionFields } from "../kimi-engine.js";
4
+ import { resolveKimiHome } from "../kimi-home.js";
5
+ import { recoverSessionIdFromStore } from "../session-recovery.js";
6
+ import { formatBudgetDuration } from "../render.js";
4
7
  /**
5
8
  * Reassemble prose final text from cli-client records.
6
9
  *
@@ -112,12 +115,42 @@ export function assertCliResultSuccess(result, stage) {
112
115
  });
113
116
  }
114
117
  }
115
- /** Timeout-only capture, called after cancellation has taken precedence. No title sync on failure. */
116
- export function captureTimeoutPartialOutput(classified, store, job) {
118
+ /** Timeout-only capture. Cancellation takes precedence, including while the
119
+ * asynchronous store scan is in flight. No title sync on failure. */
120
+ export async function captureTimeoutPartialOutput(classified, store, job, env, cancellationSignal) {
121
+ if (cancellationSignal?.aborted)
122
+ return undefined;
117
123
  const timeout = classified.cause;
118
124
  if (!(timeout instanceof ResponseTimeoutError) || !timeout.partialResult)
119
125
  return undefined;
120
126
  const result = timeout.partialResult;
127
+ let sessionId = result.sessionId;
128
+ let source = sessionId ? "announced" : null;
129
+ const details = classified instanceof RuntimeError ? classified.details : timeout.details;
130
+ details.kimi_session_id_source = job.kimi_session_id_source;
131
+ // The persisted plan is plugin-owned. Recovery never runs on cancellation,
132
+ // v1, successful turns, teardown rejection, or an already-known resume id.
133
+ if (!sessionId && !job.kimi_session_id && job.intended_engine === "native-v2") {
134
+ // Without the system.version marker the row has no proven engine, and
135
+ // resume would refuse the id as lineage-unknown; do not advertise one.
136
+ if (result.observedEngine === null) {
137
+ details.session_recovery = "engine_unobserved";
138
+ }
139
+ else {
140
+ const recovered = await recoverSessionIdFromStore({
141
+ kimiHome: resolveKimiHome(env, job.cwd), cwd: job.cwd,
142
+ spawnedAt: result.spawnedAt, settledAt: result.settledAt, promptDigest: job.prompt_digest,
143
+ store, jobId: job.job_id,
144
+ });
145
+ sessionId = recovered.sessionId;
146
+ if (sessionId)
147
+ source = "recovered_session_dir";
148
+ else
149
+ details.session_recovery = recovered.reason;
150
+ }
151
+ }
152
+ if (cancellationSignal?.aborted)
153
+ return undefined;
121
154
  // Best-effort metadata only. The row is still `running`, and a session id
122
155
  // written here can collide with `jobs_running_session_idx` when a resume of
123
156
  // the same session was started while this job ran. A store error must never
@@ -125,11 +158,22 @@ export function captureTimeoutPartialOutput(classified, store, job) {
125
158
  // than the metadata, so swallow and fall through.
126
159
  try {
127
160
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
128
- if (result.sessionId !== undefined && result.sessionId.length > 0 && result.sessionId !== job.kimi_session_id) {
129
- store.updateRunningJob(job.job_id, { kimi_session_id: result.sessionId });
161
+ if (sessionId) {
162
+ const updated = source === "recovered_session_dir"
163
+ ? store.claimRecoveredSessionId(job.job_id, sessionId)
164
+ : store.updateRunningJob(job.job_id, { kimi_session_id: sessionId, kimi_session_id_source: source });
165
+ if (updated?.kimi_session_id === sessionId && updated.kimi_session_id_source === source) {
166
+ details.kimi_session_id = sessionId;
167
+ details.kimi_session_id_source = source;
168
+ }
169
+ else
170
+ details[source === "recovered_session_dir" ? "session_recovery" : "session_id_persistence"] = "persistence_failed";
130
171
  }
131
172
  }
132
173
  catch {
174
+ // `session_recovery` describes only the recovery branch; an announced id or
175
+ // the provenance update failing is a different fact.
176
+ details[source === "recovered_session_dir" ? "session_recovery" : "session_id_persistence"] = "persistence_failed";
133
177
  // Diagnostics stay in the stream log; the failure transition proceeds.
134
178
  }
135
179
  return {
@@ -141,3 +185,8 @@ export function captureTimeoutPartialOutput(classified, store, job) {
141
185
  budgetMs: timeout.details.budget_ms,
142
186
  };
143
187
  }
188
+ export function warnInheritedBudget(job, stderr) {
189
+ if (job.budget_source === "inherited" && job.budget_ms !== null) {
190
+ stderr.write(`[${job.command_type}] budget ${formatBudgetDuration(job.budget_ms)} inherited from job ${job.resumed_from_job_id}; pass --budget to override\n`);
191
+ }
192
+ }
@@ -144,6 +144,7 @@ export async function runPursue(argv, context) {
144
144
  thinking: parsed.thinking ?? null,
145
145
  background: false,
146
146
  budget_ms: parsed.budgetMs ?? KIMI_PURSUE_DEFAULT_BUDGET_MS,
147
+ budget_source: parsed.budgetMs !== undefined ? "explicit" : "default",
147
148
  pid: process.pid,
148
149
  kimi_pid: null,
149
150
  status: "running",
@@ -250,7 +251,7 @@ async function executePursueJob(jobId, prompt, objective, budgetMs, context) {
250
251
  if (result.sessionId !== undefined &&
251
252
  result.sessionId.length > 0 &&
252
253
  result.sessionId !== job.kimi_session_id) {
253
- store.updateRunningJob(job.job_id, { kimi_session_id: result.sessionId });
254
+ store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
254
255
  }
255
256
  await syncKimiSessionTitle({
256
257
  promptText: objective,
@@ -17,7 +17,7 @@ import { parseRescueArgs } from "../parsing.js";
17
17
  import { readArtifact, renderManagedJobOutput, writeArtifact } from "../render.js";
18
18
  import { startBackgroundJob } from "../background-spawn.js";
19
19
  import { hookRefusalDetails, hookRefusalRetryProtocol, maybeWarnHookMissing, verifyHookInstalled, } from "../hooks/install.js";
20
- import { captureTimeoutPartialOutput, assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing } from "./cli-helpers.js";
20
+ import { warnInheritedBudget, captureTimeoutPartialOutput, assertCliResultSuccess, reassembleProseFromRecords, warnIfSessionIdMissing } from "./cli-helpers.js";
21
21
  import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js";
22
22
  export { describeMissingResult } from "../background-spawn.js";
23
23
  // v1.0 cutover note (PR 3):
@@ -72,10 +72,13 @@ export async function runRescue(argv, context) {
72
72
  thinking: parsed.thinking ?? null,
73
73
  background: parsed.background,
74
74
  budget_ms: parsed.budgetMs ?? (sessionResolution.sourceJob?.operation_kind === "rescue" ? sessionResolution.sourceJob.budget_ms : null) ?? null,
75
+ budget_source: parsed.budgetMs !== undefined ? "explicit"
76
+ : sessionResolution.sourceJob?.operation_kind === "rescue" && sessionResolution.sourceJob.budget_ms !== null ? "inherited" : "default",
75
77
  pid: parsed.background ? null : process.pid,
76
78
  kimi_pid: null,
77
79
  status: "running",
78
80
  kimi_session_id: sessionResolution.kimiSessionId,
81
+ kimi_session_id_source: sessionResolution.sourceJob?.kimi_session_id_source ?? null,
79
82
  ...persistedExecutionPlanFields(executionPlan),
80
83
  agent_profile: RESCUE_AGENT_PROFILE_PLACEHOLDER,
81
84
  prompt_digest: digestPrompt(prompt),
@@ -97,6 +100,7 @@ export async function runRescue(argv, context) {
97
100
  await markJobFailed(store, paths, job, classified, rescueConfig.cancellation.failedSummary, { phase: "failed" });
98
101
  throw classified;
99
102
  }
103
+ warnInheritedBudget(job, context.stderr);
100
104
  if (parsed.background) {
101
105
  return startBackgroundJob(job, prompt, context, paths, {
102
106
  workerKind: "rescue",
@@ -196,11 +200,10 @@ export async function executeRescueJob(jobId, prompt, context, options) {
196
200
  }
197
201
  assertCliResultSuccess(result, "rescue.runtime");
198
202
  if (result.sessionId !== undefined &&
199
- result.sessionId.length > 0 &&
200
- result.sessionId !== job.kimi_session_id) {
203
+ result.sessionId.length > 0) {
201
204
  // length>0 guard: empty-string capture would poison the row. (Kimi
202
205
  // alpha.4 challenge finding #3.)
203
- store.updateRunningJob(job.job_id, { kimi_session_id: result.sessionId });
206
+ store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
204
207
  }
205
208
  await syncKimiSessionTitle({
206
209
  promptText: prompt,
@@ -246,16 +249,16 @@ export async function executeRescueJob(jobId, prompt, context, options) {
246
249
  }) ?? job);
247
250
  }
248
251
  catch (error) {
249
- if (handlers.cancelling) {
250
- const cancelledError = new RuntimeError(cancel.errorCodes.cancelled, cancel.cancelMessages.default, "rescue.runtime", error instanceof Error ? { cause: error } : undefined);
251
- return await markJobCancelled(store, paths, job, cancel.cancelledSummary, cancelledError, { phase: "cancelled" });
252
- }
253
252
  const classified = classifyManagedCommandFailure(error, "rescue", job.job_id, {
254
253
  preserveStage: true,
255
254
  background: job.background,
256
255
  budgetMs: job.budget_ms ?? KIMI_RESCUE_PROMPT_TIMEOUT_MS,
257
256
  });
258
- const partialOutput = captureTimeoutPartialOutput(classified, store, job);
257
+ const partialOutput = await captureTimeoutPartialOutput(classified, store, job, context.env, handlers.signal);
258
+ if (handlers.cancelling) {
259
+ const cancelledError = new RuntimeError(cancel.errorCodes.cancelled, cancel.cancelMessages.default, "rescue.runtime", error instanceof Error ? { cause: error } : undefined);
260
+ return await markJobCancelled(store, paths, job, cancel.cancelledSummary, cancelledError, { phase: "cancelled" });
261
+ }
259
262
  return await markJobFailed(store, paths, job, classified, cancel.failedSummary, { phase: "failed", partialOutput });
260
263
  }
261
264
  finally {
@@ -2,7 +2,7 @@ import { RuntimeError } from "../errors.js";
2
2
  import { resolveRepoIdentity } from "../git.js";
3
3
  import { sweepStaleJobs } from "../jobs.js";
4
4
  import { reconcileHistoricalJobProvenance } from "../kimi-engine.js";
5
- import { withJobStore } from "../job-store.js";
5
+ import { renderBudgetSource, withJobStore } from "../job-store.js";
6
6
  import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
7
7
  import { parseJobLookupArgs } from "../parsing.js";
8
8
  import { readArtifact, renderTerminalJobArtifact } from "../render.js";
@@ -43,6 +43,8 @@ function renderResultEnvelope(job, artifactPath, body) {
43
43
  kind: job.command_type,
44
44
  status: job.status,
45
45
  budget_ms: job.budget_ms,
46
+ budget_source: renderBudgetSource(job),
47
+ kimi_session_id_source: job.kimi_session_id_source,
46
48
  kimi_session_id: job.kimi_session_id,
47
49
  // True when a budget timeout preserved assistant prose in the artifact.
48
50
  partial_output: job.status === "failed" && Number(job.error?.details?.partial_output_chars ?? 0) > 0,
@@ -153,6 +153,7 @@ async function executeReviewGate(payload, assistantMessage, context) {
153
153
  kimi_pid: null,
154
154
  status: "running",
155
155
  kimi_session_id: null,
156
+ budget_source: "default",
156
157
  ...persistedExecutionPlanFields(executionPlan),
157
158
  agent_profile: REVIEW_GATE_AGENT_PROFILE_PLACEHOLDER,
158
159
  prompt_digest: digestPrompt(prompt),
@@ -195,7 +196,7 @@ async function executeReviewGate(payload, assistantMessage, context) {
195
196
  // can't synthesize empty strings today, but the guard locks the
196
197
  // SQLite contract independent of upstream behavior).
197
198
  activeStore.updateRunningJob(job.job_id, {
198
- kimi_session_id: result.sessionId,
199
+ kimi_session_id_source: "announced", kimi_session_id: result.sessionId,
199
200
  });
200
201
  }
201
202
  const finalText = reassembleProseFromRecords(result.records);
@@ -82,6 +82,7 @@ export async function runReview(argv, context, commandType) {
82
82
  thinking: parsed.thinking ?? null,
83
83
  background: false,
84
84
  budget_ms: parsed.budgetMs ?? null,
85
+ budget_source: parsed.budgetMs !== undefined ? "explicit" : "default",
85
86
  pid: process.pid,
86
87
  kimi_pid: null,
87
88
  status: "running",
@@ -128,7 +129,7 @@ export async function runReview(argv, context, commandType) {
128
129
  // poison the row — consumers reading SQLite distinguish "missing"
129
130
  // (NULL) from "captured" (non-empty UUID) only via NULL-ness.
130
131
  // (Kimi alpha.4 challenge finding #3.)
131
- store.updateRunningJob(job.job_id, { kimi_session_id: result.sessionId });
132
+ store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
132
133
  }
133
134
  await syncKimiSessionTitle({
134
135
  promptText: previewPrompt,
@@ -159,13 +160,13 @@ export async function runReview(argv, context, commandType) {
159
160
  return rendered.output;
160
161
  }
161
162
  catch (error) {
163
+ const classified = classifyManagedCommandFailure(error, commandType, job.job_id, { background: job.background, budgetMs: job.budget_ms ?? KIMI_REVIEW_PROMPT_TIMEOUT_MS });
164
+ const partialOutput = await captureTimeoutPartialOutput(classified, store, job, context.env, handlers.signal);
162
165
  if (handlers.cancelling) {
163
166
  const cancelledError = new RuntimeError(cancel.errorCodes.cancelled, cancel.cancelMessages.default, `${commandType}.runtime`, error instanceof Error ? { cause: error } : undefined);
164
167
  await markJobCancelled(store, paths, job, cancel.cancelledSummary, cancelledError);
165
168
  throw cancelledError;
166
169
  }
167
- const classified = classifyManagedCommandFailure(error, commandType, job.job_id, { background: job.background, budgetMs: job.budget_ms ?? KIMI_REVIEW_PROMPT_TIMEOUT_MS });
168
- const partialOutput = captureTimeoutPartialOutput(classified, store, job);
169
170
  await markJobFailed(store, paths, job, classified, cancel.failedSummary, { partialOutput });
170
171
  throw classified;
171
172
  }
@@ -2,7 +2,7 @@ import { RuntimeError } from "../errors.js";
2
2
  import { resolveRepoIdentity } from "../git.js";
3
3
  import { sweepStaleJobs } from "../jobs.js";
4
4
  import { reconcileHistoricalJobProvenance } from "../kimi-engine.js";
5
- import { withJobStore } from "../job-store.js";
5
+ import { renderBudgetSource, withJobStore } from "../job-store.js";
6
6
  import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
7
7
  import { parseJobLookupArgs } from "../parsing.js";
8
8
  export async function runStatus(argv, context) {
@@ -22,6 +22,6 @@ export async function runStatus(argv, context) {
22
22
  throw new RuntimeError("JOB_NOT_FOUND", "No matching job was found for status.", "status.lookup");
23
23
  }
24
24
  const job = await reconcileHistoricalJobProvenance(store, found);
25
- return `${JSON.stringify(job, null, 2)}\n`;
25
+ return `${JSON.stringify({ ...job, budget_source: renderBudgetSource(job) }, null, 2)}\n`;
26
26
  });
27
27
  }
@@ -211,6 +211,7 @@ async function runReadSwarm(parsed, objective, context) {
211
211
  thinking: parsed.thinking ?? null,
212
212
  background: false,
213
213
  budget_ms: parsed.budgetMs ?? KIMI_SWARM_DEFAULT_BUDGET_MS,
214
+ budget_source: parsed.budgetMs !== undefined ? "explicit" : "default",
214
215
  pid: process.pid,
215
216
  kimi_pid: null,
216
217
  status: "running",
@@ -310,6 +311,7 @@ async function runWriteSwarm(parsed, objective, context) {
310
311
  thinking: parsed.thinking ?? null,
311
312
  background: false,
312
313
  budget_ms: parsed.budgetMs ?? KIMI_SWARM_DEFAULT_BUDGET_MS,
314
+ budget_source: parsed.budgetMs !== undefined ? "explicit" : "default",
313
315
  pid: process.pid,
314
316
  kimi_pid: null,
315
317
  status: "running",
@@ -502,7 +504,7 @@ writeMode) {
502
504
  if (result.sessionId !== undefined &&
503
505
  result.sessionId.length > 0 &&
504
506
  result.sessionId !== job.kimi_session_id) {
505
- store.updateRunningJob(job.job_id, { kimi_session_id: result.sessionId });
507
+ store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
506
508
  }
507
509
  await syncKimiSessionTitle({
508
510
  promptText: objective,
@@ -417,15 +417,23 @@ export function normalizeTopLevelInlineHooks(contents, lineEnding) {
417
417
  entryCount: hooks.length,
418
418
  };
419
419
  }
420
+ // Same defaults as smol-toml 1.9.1 parse(); this walker only locates assignments.
421
+ // tests/runtime/toml-walker-context.test.ts asserts these against the vendored parse().
422
+ export function assignmentParseContext(s) {
423
+ return { s, p: s.charCodeAt(0) === 0xfeff ? 1 : 0, d: 1_000, bi: false, ld: true, uk: 0 };
424
+ }
420
425
  function findTopLevelHooksAssignment(contents) {
421
- let ptr = skipVoid(contents, 0);
422
- while (ptr < contents.length) {
426
+ const ctx = assignmentParseContext(contents);
427
+ skipVoid(ctx);
428
+ while (ctx.p < contents.length) {
423
429
  // A root-level direct assignment cannot resume after entering a table.
424
- if (contents[ptr] === "[")
430
+ if (contents[ctx.p] === "[")
425
431
  return null;
426
- const declarationStart = ptr;
427
- const [key, valueStart] = parseKey(contents, ptr);
428
- const [, valueEnd] = extractValue(contents, valueStart, undefined, 1_000, undefined);
432
+ const declarationStart = ctx.p;
433
+ const key = parseKey(ctx);
434
+ skipVoid(ctx, true, true);
435
+ extractValue(ctx, undefined);
436
+ const valueEnd = ctx.p;
429
437
  if (key.length === 1 && key[0] === "hooks") {
430
438
  const lineStart = contents.lastIndexOf("\n", declarationStart - 1) + 1;
431
439
  const start = /^[\t ]*$/.test(contents.slice(lineStart, declarationStart))
@@ -445,7 +453,7 @@ function findTopLevelHooksAssignment(contents) {
445
453
  }
446
454
  return { start, end, hadLineEnding };
447
455
  }
448
- ptr = skipVoid(contents, valueEnd);
456
+ skipVoid(ctx);
449
457
  }
450
458
  return null;
451
459
  }