kimi-plugin-cc 2.3.1 → 2.4.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 (71) 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 +19 -0
  5. package/README.md +67 -7
  6. package/dist/cli-client.js +13 -3
  7. package/dist/commands/ask.js +4 -2
  8. package/dist/commands/cancel.js +43 -4
  9. package/dist/commands/cli-helpers.js +5 -1
  10. package/dist/commands/pursue.js +3 -2
  11. package/dist/commands/rescue.js +3 -2
  12. package/dist/commands/review-gate.js +2 -4
  13. package/dist/commands/review.js +4 -2
  14. package/dist/commands/swarm.js +195 -37
  15. package/dist/companion.js +13 -40
  16. package/dist/help.js +2 -0
  17. package/dist/hooks/config-safety.js +4 -3
  18. package/dist/job-id.js +12 -0
  19. package/dist/job-store.js +59 -0
  20. package/dist/jobs.js +9 -1
  21. package/dist/kimi-engine.js +1 -0
  22. package/dist/mcp/binding.js +58 -0
  23. package/dist/mcp/job-control.js +345 -0
  24. package/dist/mcp/launcher.js +257 -0
  25. package/dist/mcp/operations.js +301 -0
  26. package/dist/mcp/protocol.js +275 -0
  27. package/dist/mcp/server.js +8 -0
  28. package/dist/mcp/stdio.js +78 -0
  29. package/dist/mcp/supervised-child.js +15 -0
  30. package/dist/mcp/tool-result.js +7 -0
  31. package/dist/mcp/workspace.js +34 -0
  32. package/dist/model-command.js +32 -0
  33. package/dist/process-tree.js +48 -17
  34. package/dist/swarm-activation.js +812 -0
  35. package/dist/version.js +1 -1
  36. package/docs/migration.md +12 -2
  37. package/docs/registry-releases.md +11 -9
  38. package/docs/safety.md +31 -5
  39. package/package.json +8 -4
  40. package/plugins/kimi-codex/.codex-plugin/plugin.json +7 -4
  41. package/plugins/kimi-codex/.mcp.json +28 -0
  42. package/plugins/kimi-codex/dist/cli-client.js +13 -3
  43. package/plugins/kimi-codex/dist/commands/ask.js +4 -2
  44. package/plugins/kimi-codex/dist/commands/cancel.js +43 -4
  45. package/plugins/kimi-codex/dist/commands/cli-helpers.js +5 -1
  46. package/plugins/kimi-codex/dist/commands/pursue.js +3 -2
  47. package/plugins/kimi-codex/dist/commands/rescue.js +3 -2
  48. package/plugins/kimi-codex/dist/commands/review-gate.js +2 -4
  49. package/plugins/kimi-codex/dist/commands/review.js +4 -2
  50. package/plugins/kimi-codex/dist/commands/swarm.js +195 -37
  51. package/plugins/kimi-codex/dist/companion.js +13 -40
  52. package/plugins/kimi-codex/dist/help.js +2 -0
  53. package/plugins/kimi-codex/dist/hooks/config-safety.js +4 -3
  54. package/plugins/kimi-codex/dist/job-id.js +12 -0
  55. package/plugins/kimi-codex/dist/job-store.js +59 -0
  56. package/plugins/kimi-codex/dist/jobs.js +9 -1
  57. package/plugins/kimi-codex/dist/kimi-engine.js +1 -0
  58. package/plugins/kimi-codex/dist/mcp/binding.js +58 -0
  59. package/plugins/kimi-codex/dist/mcp/job-control.js +345 -0
  60. package/plugins/kimi-codex/dist/mcp/launcher.js +257 -0
  61. package/plugins/kimi-codex/dist/mcp/operations.js +301 -0
  62. package/plugins/kimi-codex/dist/mcp/protocol.js +275 -0
  63. package/plugins/kimi-codex/dist/mcp/server.js +8 -0
  64. package/plugins/kimi-codex/dist/mcp/stdio.js +78 -0
  65. package/plugins/kimi-codex/dist/mcp/supervised-child.js +15 -0
  66. package/plugins/kimi-codex/dist/mcp/tool-result.js +7 -0
  67. package/plugins/kimi-codex/dist/mcp/workspace.js +34 -0
  68. package/plugins/kimi-codex/dist/model-command.js +32 -0
  69. package/plugins/kimi-codex/dist/process-tree.js +48 -17
  70. package/plugins/kimi-codex/dist/swarm-activation.js +812 -0
  71. package/plugins/kimi-codex/dist/version.js +1 -1
@@ -1,8 +1,9 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { allocateJobId } from "../job-id.js";
2
2
  import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { createCliCancellationHandlers } from "../cli-cancellation.js";
5
- import { runCliPromptWithBudget } from "../cli-client.js";
5
+ import { ResponseTimeoutError, runCliPromptWithBudget } from "../cli-client.js";
6
+ import { deactivateSwarmSession, preactivateSwarmSession, requiresSwarmPreactivation, } from "../swarm-activation.js";
6
7
  import { executionPlanFromPersisted, observedExecutionFields, persistedExecutionPlanFields, prepareKimiExecutionPlan, } from "../kimi-engine.js";
7
8
  import { RuntimeError } from "../errors.js";
8
9
  import { captureWorktreePatch, createEphemeralWorktree, hasBornHead, isWorkingTreeDirty, pruneWorktrees, removeWorktree, resolveRepoIdentity, } from "../git.js";
@@ -24,7 +25,8 @@ import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js
24
25
  // only the wall-clock hard bound. Read/write modes reuse review/rescue lineage,
25
26
  // and the mandatory PreToolUse hook enforces read-only policy or trusted-worktree
26
27
  // write confinement on the coordinator and every subagent. Write mode requires
27
- // kimi-code 0.18.0+.
28
+ // kimi-code 0.18.0+. Exact 2.3.2 additionally activates a same-job session through
29
+ // a bounded loopback web lifecycle, then resumes it through the normal print path.
28
30
  const SWARM_SUMMARY_MAX = 120;
29
31
  const SWARM_AGENT_PROFILE = "<swarm>";
30
32
  const SWARM_WRITE_AGENT_PROFILE = "<swarm-write>";
@@ -87,7 +89,7 @@ export function resolveSwarmWriteMaxConcurrency(requested) {
87
89
  * subagent, so slimming this text does not move the safety boundary. The optional
88
90
  * `cap` is a SOFT subagent-count hint (the hook cannot enforce a count). The HARD
89
91
  * concurrency ceiling is separate: --max-concurrency →
90
- * KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY (see executeSwarmJob / cli-client buildEnv).
92
+ * KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY (see executeSwarmJob / cli-client buildKimiCliEnvironment).
91
93
  * The --budget wall-clock ceiling is the always-on hard bound on the whole run.
92
94
  */
93
95
  export function buildSwarmPrompt(objective, cap) {
@@ -180,7 +182,7 @@ async function runReadSwarm(parsed, objective, context) {
180
182
  env: context.env,
181
183
  });
182
184
  const prompt = buildSwarmPrompt(objective, parsed.cap);
183
- const jobId = randomUUID();
185
+ const jobId = allocateJobId(context);
184
186
  const logPath = path.join(paths.logsDir, `swarm-${jobId}.jsonl`);
185
187
  const job = store.createJob({
186
188
  job_id: jobId,
@@ -257,7 +259,7 @@ async function runWriteSwarm(parsed, objective, context) {
257
259
  context.stderr.write("[kimi-plugin-cc] /kimi:swarm --write bases its worktree on HEAD — your uncommitted changes are NOT visible to the swarm. Commit or stash them first if the swarm needs them.\n");
258
260
  }
259
261
  const prompt = buildSwarmWritePrompt(objective, parsed.cap);
260
- const jobId = randomUUID();
262
+ const jobId = allocateJobId(context);
261
263
  const logPath = path.join(paths.logsDir, `swarm-write-${jobId}.jsonl`);
262
264
  // Namespace worktrees per repo so the cross-repo sweep below only ever
263
265
  // touches THIS repo's subtree (the worktreesDir is a single global dir shared
@@ -343,7 +345,7 @@ async function runWriteSwarm(parsed, objective, context) {
343
345
  }
344
346
  finally {
345
347
  if (preserveWorktree) {
346
- context.stderr.write(`[kimi-plugin-cc] swarm-write patch capture failed; worktree retained at ${worktreePath}.\n`);
348
+ context.stderr.write(`[kimi-plugin-cc] swarm-write patch capture or session cleanup failed; worktree retained at ${worktreePath}.\n`);
347
349
  }
348
350
  else {
349
351
  await removeWorktree(repoIdentity.repoRoot, worktreePath);
@@ -465,43 +467,163 @@ writeMode) {
465
467
  }
466
468
  }
467
469
  const handlers = createCliCancellationHandlers();
470
+ const budgetDeadline = Date.now() + budgetMs;
471
+ let activationOptions;
472
+ let preactivated;
473
+ let deactivationRequired = false;
474
+ let cleanupAttempted = false;
475
+ let cleanupFailure;
476
+ let finalText;
477
+ const commandLabel = writeMode ? "swarm-write" : "swarm";
478
+ const recoveryCleanupTimeoutMs = 2_000;
479
+ const cleanupSignal = (timeoutMs) => AbortSignal.any([
480
+ handlers.signal,
481
+ AbortSignal.timeout(Math.max(1, timeoutMs)),
482
+ ]);
483
+ const attemptDeactivation = async (identity, fallbackStage, timeoutMs = Math.max(1, budgetDeadline - Date.now())) => {
484
+ cleanupAttempted = true;
485
+ try {
486
+ await deactivateSwarmSession({
487
+ ...activationOptions,
488
+ ...identity,
489
+ signal: cleanupSignal(timeoutMs),
490
+ });
491
+ deactivationRequired = false;
492
+ return undefined;
493
+ }
494
+ catch (error) {
495
+ return error instanceof RuntimeError
496
+ ? error
497
+ : new RuntimeError("SWARM_SESSION_CLEANUP_FAILED", "Swarm session cleanup failed without a safe diagnostic payload.", fallbackStage);
498
+ }
499
+ };
468
500
  try {
469
501
  const executionPlan = executionPlanFromPersisted(job);
502
+ activationOptions = {
503
+ cwd: writeMode?.spawnCwd ?? job.cwd,
504
+ env: context.env,
505
+ command: executionPlan.command,
506
+ prefixArgs: executionPlan.prefixArgs,
507
+ executionPlan,
508
+ commandLabel,
509
+ model: job.model ?? undefined,
510
+ thinkingEffort: job.thinking_effort ?? undefined,
511
+ swarmMaxConcurrency: maxConcurrency,
512
+ trustedWorkspaceRoot: writeMode?.workspaceRoot,
513
+ expectedVersion: executionPlan.kimiVersion ?? undefined,
514
+ };
515
+ if (requiresSwarmPreactivation(executionPlan.kimiVersion)) {
516
+ store.updateRunningJob(job.job_id, { phase: "turn-running" });
517
+ const activationController = new AbortController();
518
+ const onParentAbort = () => activationController.abort();
519
+ let activationTimedOut = false;
520
+ handlers.signal.addEventListener("abort", onParentAbort, { once: true });
521
+ if (handlers.signal.aborted)
522
+ onParentAbort();
523
+ const activationTimer = setTimeout(() => {
524
+ activationTimedOut = true;
525
+ activationController.abort();
526
+ }, Math.max(1, budgetDeadline - Date.now()));
527
+ activationTimer.unref();
528
+ try {
529
+ preactivated = await preactivateSwarmSession({
530
+ ...activationOptions,
531
+ signal: activationController.signal,
532
+ });
533
+ deactivationRequired = true;
534
+ store.updateRunningJob(job.job_id, {
535
+ kimi_session_id: preactivated.sessionId,
536
+ kimi_session_id_source: "preactivated",
537
+ });
538
+ }
539
+ catch (error) {
540
+ const activationDidTimeOut = activationTimedOut;
541
+ clearTimeout(activationTimer);
542
+ handlers.signal.removeEventListener("abort", onParentAbort);
543
+ const details = error instanceof RuntimeError ? error.details : {};
544
+ const sessionId = typeof details.session_id === "string" ? details.session_id : undefined;
545
+ const workspaceId = typeof details.workspace_id === "string" ? details.workspace_id : undefined;
546
+ const hasSessionIdentity = sessionId !== undefined && workspaceId !== undefined;
547
+ if (error instanceof RuntimeError &&
548
+ error.code === "SWARM_SESSION_CLEANUP_FAILED" &&
549
+ details.process_cleanup_unverified === true) {
550
+ cleanupFailure = error;
551
+ }
552
+ if (hasSessionIdentity) {
553
+ store.updateRunningJob(job.job_id, {
554
+ kimi_session_id: sessionId,
555
+ kimi_session_id_source: "preactivated",
556
+ });
557
+ }
558
+ if (hasSessionIdentity && !handlers.cancelling) {
559
+ const sessionCleanupFailure = await attemptDeactivation({ sessionId, workspaceId }, "swarm.activation-cleanup", recoveryCleanupTimeoutMs);
560
+ if (sessionCleanupFailure)
561
+ cleanupFailure = sessionCleanupFailure;
562
+ }
563
+ else if (hasSessionIdentity && cleanupFailure === undefined) {
564
+ cleanupFailure = new RuntimeError("SWARM_SESSION_CLEANUP_FAILED", "Swarm session cleanup was not completed after activation was cancelled.", "swarm.activation-cleanup", { details: { cancelled: true, cleanup_unverified: true } });
565
+ }
566
+ if (activationDidTimeOut && !handlers.cancelling) {
567
+ throw new ResponseTimeoutError(budgetMs, "swarm.activation", commandLabel, undefined, error);
568
+ }
569
+ throw error;
570
+ }
571
+ finally {
572
+ clearTimeout(activationTimer);
573
+ handlers.signal.removeEventListener("abort", onParentAbort);
574
+ }
575
+ }
576
+ const promptBudgetMs = budgetDeadline - Date.now();
577
+ if (promptBudgetMs <= 0)
578
+ throw new ResponseTimeoutError(budgetMs, "swarm.prompt", commandLabel);
470
579
  store.updateRunningJob(job.job_id, { phase: "turn-running" });
471
580
  const result = await runCliPromptWithBudget({
472
- // Read mode spawns in the user's cwd; WRITE mode spawns in the throwaway
473
- // worktree so coder subagents edit there (job.cwd stays the user's real
474
- // cwd for lineage). This is the load-bearing isolation seam.
475
- cwd: writeMode?.spawnCwd ?? job.cwd,
581
+ cwd: activationOptions.cwd,
476
582
  env: context.env,
477
583
  command: executionPlan.command,
478
584
  prefixArgs: [...executionPlan.prefixArgs],
479
585
  executionPlan,
480
586
  prompt,
481
- // "swarm" → read-only-plus-AgentSwarm allowlist; "swarm-write" → the same
482
- // PLUS rescue-grade write confinement to the trusted worktree root. The
483
- // label drives the hook for the coordinator AND every spawned subagent.
484
- commandLabel: writeMode ? "swarm-write" : "swarm",
485
- // Always exported as KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY. kimi-code
486
- // 0.18.0+ enforces this hard peak ceiling; older read-only binaries ignore
487
- // it and retain only the wall-clock hard bound. Write mode requires
488
- // 0.18.0+. Distinct from --cap, the soft total-count prompt hint.
587
+ commandLabel,
489
588
  swarmMaxConcurrency: maxConcurrency,
490
- // Trusted allowlist root for the swarm-write hook (forge-proof; ignored
491
- // by other labels). Only set in write mode.
492
589
  trustedWorkspaceRoot: writeMode?.workspaceRoot,
493
590
  model: job.model ?? undefined,
494
591
  thinkingEffort: job.thinking_effort ?? undefined,
592
+ resumeSessionId: preactivated?.sessionId,
495
593
  logPath: job.stream_log_path,
594
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
496
595
  signal: handlers.signal,
497
- }, budgetMs, "swarm.prompt");
596
+ }, promptBudgetMs, "swarm.prompt");
498
597
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
598
+ finalText = reassembleProseFromRecords(result.records);
599
+ let resultError;
499
600
  if (handlers.cancelling) {
500
- throw new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
601
+ resultError = new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
602
+ }
603
+ else {
604
+ try {
605
+ assertCliResultSuccess(result, "swarm.runtime");
606
+ if (preactivated && result.sessionId !== preactivated.sessionId) {
607
+ throw new RuntimeError("SWARM_SESSION_MISMATCH", "The resumed print session differs from the preactivated swarm session.", "swarm.session-provenance", { details: { expected_session_id: preactivated.sessionId, actual_session_id: result.sessionId ?? null } });
608
+ }
609
+ }
610
+ catch (error) {
611
+ resultError = error;
612
+ }
501
613
  }
502
- assertCliResultSuccess(result, "swarm.runtime");
503
614
  warnHookEscapes(result, "swarm", context.stderr);
504
- if (result.sessionId !== undefined &&
615
+ if (preactivated) {
616
+ cleanupFailure = await attemptDeactivation(preactivated, "swarm.cleanup");
617
+ }
618
+ if (resultError !== undefined)
619
+ throw resultError;
620
+ if (handlers.cancelling) {
621
+ throw new RuntimeError("SWARM_CANCELLED", "Swarm cancelled by user request.", "swarm.runtime");
622
+ }
623
+ if (cleanupFailure)
624
+ throw cleanupFailure;
625
+ if (!preactivated &&
626
+ result.sessionId !== undefined &&
505
627
  result.sessionId.length > 0 &&
506
628
  result.sessionId !== job.kimi_session_id) {
507
629
  store.updateRunningJob(job.job_id, { kimi_session_id_source: "announced", kimi_session_id: result.sessionId });
@@ -516,7 +638,7 @@ writeMode) {
516
638
  });
517
639
  warnIfSessionIdMissing(result, "swarm", job.job_id, context.stderr);
518
640
  const prose = reassembleProseFromRecords(result.records);
519
- let finalText = prose;
641
+ finalText = prose;
520
642
  if (writeMode) {
521
643
  const captured = await capturePatchArtifact(paths, job.job_id, writeMode.workspaceRoot, context.stderr, writeMode.onPatchCaptureFailure);
522
644
  if (captured === null) {
@@ -548,6 +670,21 @@ writeMode) {
548
670
  }) ?? job);
549
671
  }
550
672
  catch (error) {
673
+ if (preactivated && deactivationRequired && activationOptions) {
674
+ if (handlers.cancelling) {
675
+ cleanupFailure = new RuntimeError("SWARM_SESSION_CLEANUP_FAILED", "Swarm session cleanup was not completed after cancellation.", "swarm.cleanup", { details: { cancelled: true, cleanup_unverified: true } });
676
+ }
677
+ else if (!cleanupAttempted) {
678
+ cleanupFailure = await attemptDeactivation(preactivated, "swarm.cleanup", recoveryCleanupTimeoutMs);
679
+ }
680
+ else if (error instanceof RuntimeError && error.code === "SWARM_SESSION_CLEANUP_FAILED") {
681
+ cleanupFailure ??= error;
682
+ }
683
+ }
684
+ if (cleanupFailure) {
685
+ writeMode?.onPatchCaptureFailure?.();
686
+ context.stderr.write(`[kimi-plugin-cc] swarm session cleanup also failed: ${cleanupFailure.message}\n`);
687
+ }
551
688
  let captureFailed = writeMode !== undefined && error instanceof RuntimeError && error.code === "SWARM_PATCH_CAPTURE_FAILED";
552
689
  if (writeMode && !(error instanceof RuntimeError && error.code === "SWARM_PATCH_CAPTURE_FAILED")) {
553
690
  const captured = await capturePatchArtifact(paths, job.job_id, writeMode.workspaceRoot, context.stderr, writeMode.onPatchCaptureFailure);
@@ -556,34 +693,55 @@ writeMode) {
556
693
  context.stderr.write(`[kimi-plugin-cc] swarm-write run ended early; partial patch captured at ${captured.path}.\n`);
557
694
  }
558
695
  }
559
- const recoveryDetails = captureFailed && writeMode ? { worktreePath: writeMode.workspaceRoot } : {};
696
+ const recoveryDetails = {
697
+ ...(error instanceof RuntimeError && typeof error.details.exit_code === "number"
698
+ ? { exit_code: error.details.exit_code }
699
+ : {}),
700
+ ...((captureFailed || cleanupFailure) && writeMode ? { worktreePath: writeMode.workspaceRoot } : {}),
701
+ ...(cleanupFailure
702
+ ? {
703
+ cleanup_error: {
704
+ code: cleanupFailure.code,
705
+ stage: cleanupFailure.stage,
706
+ cancelled: handlers.cancelling,
707
+ unverified: true,
708
+ },
709
+ ...(finalText !== undefined ? { rawOutput: finalText } : {}),
710
+ }
711
+ : {}),
712
+ };
560
713
  if (handlers.cancelling) {
714
+ const cancellationCause = classifyManagedCommandFailure(error, writeMode ? "rescue" : "review", job.job_id, {
715
+ preserveStage: true,
716
+ });
561
717
  const cancelledError = new RuntimeError("SWARM_CANCELLED", captureFailed && writeMode
562
718
  ? `Swarm cancelled; patch capture failed and worktree retained at ${writeMode.workspaceRoot}.`
563
719
  : "Swarm cancelled by user request.", "swarm.runtime", {
564
720
  ...(error instanceof Error ? { cause: error } : {}),
565
- details: recoveryDetails,
721
+ details: {
722
+ ...(cancellationCause instanceof RuntimeError ? cancellationCause.details : {}),
723
+ ...recoveryDetails,
724
+ },
566
725
  });
567
726
  return await markJobCancelled(store, paths, job, "Swarm cancelled by user request.", cancelledError, {
568
727
  phase: "cancelled",
569
728
  });
570
729
  }
730
+ if (error === cleanupFailure && budgetDeadline <= Date.now() && !(error instanceof ResponseTimeoutError)) {
731
+ error = new ResponseTimeoutError(budgetMs, "swarm.cleanup", commandLabel, undefined, cleanupFailure);
732
+ }
571
733
  const classified = classifyManagedCommandFailure(error, writeMode ? "rescue" : "review", job.job_id, {
572
734
  preserveStage: true,
573
735
  });
574
- if (captureFailed && writeMode) {
575
- const withRecovery = classified instanceof RuntimeError
736
+ const withRecovery = captureFailed || cleanupFailure
737
+ ? classified instanceof RuntimeError
576
738
  ? new RuntimeError(classified.code, classified.message, classified.stage, {
577
739
  cause: classified,
578
740
  details: { ...classified.details, ...recoveryDetails },
579
741
  })
580
- : new RuntimeError("SWARM_PATCH_CAPTURE_FAILED", classified.message, "swarm.patch-capture", {
581
- cause: classified,
582
- details: recoveryDetails,
583
- });
584
- return await markJobFailed(store, paths, job, withRecovery, "Swarm failed.", { phase: "failed" });
585
- }
586
- return await markJobFailed(store, paths, job, classified, "Swarm failed.", { phase: "failed" });
742
+ : new RuntimeError(cleanupFailure?.code ?? "SWARM_PATCH_CAPTURE_FAILED", classified.message, cleanupFailure?.stage ?? "swarm.patch-capture", { cause: classified, details: recoveryDetails })
743
+ : classified;
744
+ return await markJobFailed(store, paths, job, withRecovery, "Swarm failed.", { phase: "failed" });
587
745
  }
588
746
  finally {
589
747
  handlers.dispose();
@@ -1,20 +1,17 @@
1
1
  import { companionHelp } from "./help.js";
2
- import { runAsk } from "./commands/ask.js";
3
2
  import { runModelInventory } from "./commands/models.js";
4
3
  import { runCancel } from "./commands/cancel.js";
5
- import { notImplementedCompanionCommand } from "./commands/not-implemented.js";
6
4
  import { runReplay } from "./commands/replay.js";
7
5
  import { runRepairSessions } from "./commands/repair-sessions.js";
8
6
  import { runResult } from "./commands/result.js";
9
- import { runReview } from "./commands/review.js";
10
- import { runRescue } from "./commands/rescue.js";
11
- import { runPursue } from "./commands/pursue.js";
12
- import { runSwarm } from "./commands/swarm.js";
13
7
  import { renderSetupResult, runSetup } from "./commands/setup.js";
14
8
  import { runStatus } from "./commands/status.js";
15
9
  import { runWorker } from "./commands/worker.js";
16
10
  import { formatError, RuntimeError } from "./errors.js";
17
11
  import { resolveKimiHome } from "./kimi-home.js";
12
+ import { runModelCommand } from "./model-command.js";
13
+ import { runMcpChild } from "./mcp/supervised-child.js";
14
+ import { runMcpServer } from "./mcp/server.js";
18
15
  async function main(argv) {
19
16
  const help = companionHelp(argv);
20
17
  if (help !== undefined) {
@@ -27,11 +24,16 @@ async function main(argv) {
27
24
  cwd,
28
25
  env: {
29
26
  ...process.env,
30
- KIMI_CODE_HOME: resolveKimiHome(process.env, cwd),
27
+ KIMI_CODE_HOME: command === "mcp" ? process.env.KIMI_CODE_HOME : resolveKimiHome(process.env, cwd),
31
28
  },
32
29
  stdout: process.stdout,
33
30
  stderr: process.stderr,
34
31
  };
32
+ const modelResult = await runModelCommand(argv, context);
33
+ if (modelResult !== undefined) {
34
+ context.stdout.write(modelResult);
35
+ return;
36
+ }
35
37
  switch (command) {
36
38
  case "setup": {
37
39
  if (rest.includes("--models")) {
@@ -57,41 +59,12 @@ async function main(argv) {
57
59
  }
58
60
  return;
59
61
  }
60
- case "review": {
61
- const result = await runReview(rest, context, "review");
62
- context.stdout.write(`${result}\n`);
63
- return;
64
- }
65
- case "task": {
66
- const [taskType, ...taskArgs] = rest;
67
- if (taskType === "rescue") {
68
- const result = await runRescue(taskArgs, context);
69
- context.stdout.write(result);
70
- return;
71
- }
72
- if (taskType === "challenge") {
73
- const result = await runReview(taskArgs, context, "challenge");
74
- context.stdout.write(`${result}\n`);
75
- return;
76
- }
77
- if (taskType === "pursue") {
78
- const result = await runPursue(taskArgs, context);
79
- context.stdout.write(result);
80
- return;
81
- }
82
- if (taskType === "swarm") {
83
- const result = await runSwarm(taskArgs, context);
84
- context.stdout.write(result);
85
- return;
86
- }
87
- notImplementedCompanionCommand(taskType ? `task ${taskType}` : "task");
62
+ case "mcp":
63
+ await runMcpServer(context);
88
64
  return;
89
- }
90
- case "ask": {
91
- const result = await runAsk(rest, context);
92
- context.stdout.write(`${result}\n`);
65
+ case "mcp-child":
66
+ context.stdout.write(await runMcpChild(rest, context));
93
67
  return;
94
- }
95
68
  case "status":
96
69
  context.stdout.write(await runStatus(rest, context));
97
70
  return;
@@ -35,6 +35,8 @@ export function companionHelp(argv) {
35
35
  case "replay": return usage(command, "<job-id>", REPLAY_SUPPORTED_FLAGS, "Replay a completed job's persisted stream log.");
36
36
  case "repair-sessions": return usage(command, "[options]", REPAIR_SESSIONS_SUPPORTED_FLAGS, "Preview session metadata repair; --apply writes, --all selects all host-owned repositories.");
37
37
  case "worker": return "Usage: companion.sh worker <ask|rescue> <job-id>. Internal detached-worker entry point; launched by ask/rescue, not manually.";
38
+ case "mcp": return "Usage: companion.sh mcp. Run the Kimi MCP stdio server; normally launched by Claude Code or Codex plugin configuration.";
39
+ case "mcp-child": return "Usage: companion.sh mcp-child <job-uuid> <model-operation> [arguments]. Internal supervised MCP child; launched by the MCP server, not manually.";
38
40
  default: return undefined;
39
41
  }
40
42
  }
@@ -594,8 +594,8 @@ function validateVersionSensitiveEvents(events, version) {
594
594
  }
595
595
  // Exact schemas reviewed at 2.0.0 (1b89e4b0), 2.0.1 (caf7d4e2),
596
596
  // 2.0.2 (9d07f634), 2.1.0 (52437299), 2.1.1 (f67e6398), 2.2.0 (472192c3),
597
- // 2.3.0 (b5e28fd6) and 2.3.1 (684bae9e): the additive events and HookDefSchema
598
- // are unchanged. Require the full version string AND matching components;
597
+ // 2.3.0 (b5e28fd6), 2.3.1 (684bae9e) and 2.3.2 (b8dd3a13): the additive
598
+ // events and HookDefSchema are unchanged. Require the full version string AND matching components;
599
599
  // no inferred 2.x compatibility.
600
600
  if (version.major === 2 && ((version.minor === 0 && ((version.version === "2.0.0" && version.patch === 0) ||
601
601
  (version.version === "2.0.1" && version.patch === 1) ||
@@ -604,7 +604,8 @@ function validateVersionSensitiveEvents(events, version) {
604
604
  (version.version === "2.1.1" && version.patch === 1))) ||
605
605
  (version.minor === 2 && ((version.version === "2.2.0" && version.patch === 0))) ||
606
606
  (version.minor === 3 && ((version.version === "2.3.0" && version.patch === 0) ||
607
- (version.version === "2.3.1" && version.patch === 1))))) {
607
+ (version.version === "2.3.1" && version.patch === 1) ||
608
+ (version.version === "2.3.2" && version.patch === 2))))) {
608
609
  return { valid: true };
609
610
  }
610
611
  if (version.major !== 0) {
@@ -0,0 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { RuntimeError } from "./errors.js";
3
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
4
+ export function allocateJobId(context) {
5
+ const fixed = context.mcpJobId;
6
+ if (fixed === undefined)
7
+ return randomUUID();
8
+ if (!UUID_PATTERN.test(fixed)) {
9
+ throw new RuntimeError("MCP_INVALID_JOB_ID", "The supervised job ID must be a valid UUID.", "mcp.child");
10
+ }
11
+ return fixed;
12
+ }
@@ -99,6 +99,25 @@ export class JobStore {
99
99
  }
100
100
  }
101
101
  this.db.exec(`
102
+ CREATE TABLE IF NOT EXISTS mcp_tasks (
103
+ task_id TEXT PRIMARY KEY,
104
+ operation_kind TEXT NOT NULL,
105
+ repo_id TEXT NOT NULL,
106
+ workspace_cwd TEXT NOT NULL,
107
+ companion_pid INTEGER,
108
+ companion_identity TEXT,
109
+ kimi_identity TEXT,
110
+ stdout_path TEXT NOT NULL,
111
+ stderr_path TEXT NOT NULL,
112
+ launch_state TEXT NOT NULL,
113
+ launch_error TEXT,
114
+ created_at TEXT NOT NULL,
115
+ attached_at TEXT,
116
+ updated_at TEXT NOT NULL
117
+ );
118
+
119
+ CREATE INDEX IF NOT EXISTS mcp_tasks_workspace_updated_idx
120
+ ON mcp_tasks (workspace_cwd, updated_at DESC);
102
121
 
103
122
  CREATE INDEX IF NOT EXISTS jobs_repo_updated_idx
104
123
  ON jobs (repo_id, updated_at DESC);
@@ -150,6 +169,9 @@ export class JobStore {
150
169
  -- 0.1.6 migration: rename adversarial_review to challenge
151
170
  UPDATE jobs SET command_type = 'challenge' WHERE command_type = 'adversarial_review';
152
171
  `);
172
+ if (!mcpTableHasColumn(this.db, "kimi_identity")) {
173
+ this.db.exec("ALTER TABLE mcp_tasks ADD COLUMN kimi_identity TEXT;");
174
+ }
153
175
  }
154
176
  catch (error) {
155
177
  // Migration failed — close the adapter so the OS handle is released
@@ -258,6 +280,40 @@ export class JobStore {
258
280
  `, repoId, sessionId);
259
281
  return row ? hydrateRow(row) : null;
260
282
  }
283
+ createMcpTask(input) {
284
+ const now = new Date().toISOString();
285
+ try {
286
+ this.db.run(`
287
+ INSERT INTO mcp_tasks (
288
+ task_id, operation_kind, repo_id, workspace_cwd, companion_pid, companion_identity, kimi_identity,
289
+ stdout_path, stderr_path, launch_state, launch_error, created_at, attached_at, updated_at
290
+ ) VALUES (
291
+ @task_id, @operation_kind, @repo_id, @workspace_cwd, NULL, NULL, NULL,
292
+ @stdout_path, @stderr_path, 'starting', NULL, @created_at, NULL, @updated_at
293
+ )
294
+ `, { ...input, created_at: now, updated_at: now });
295
+ }
296
+ catch (error) {
297
+ throw translateSqliteError(error);
298
+ }
299
+ return this.getMcpTask(input.task_id);
300
+ }
301
+ getMcpTask(taskId) {
302
+ return this.db.get("SELECT * FROM mcp_tasks WHERE task_id = ?", taskId) ?? null;
303
+ }
304
+ updateMcpTask(taskId, patch) {
305
+ const updates = ["updated_at = @updated_at"];
306
+ const params = { task_id: taskId, updated_at: new Date().toISOString() };
307
+ for (const key of ["companion_pid", "companion_identity", "kimi_identity", "launch_state", "launch_error", "attached_at"]) {
308
+ const value = patch[key];
309
+ if (value === undefined)
310
+ continue;
311
+ updates.push(`${key} = @${key}`);
312
+ params[key] = value;
313
+ }
314
+ this.db.run(`UPDATE mcp_tasks SET ${updates.join(", ")} WHERE task_id = @task_id`, params);
315
+ return this.getMcpTask(taskId);
316
+ }
261
317
  updateRunningJob(jobId, patch) {
262
318
  return this.updateWhere(jobId, patch, "status = 'running'");
263
319
  }
@@ -524,6 +580,9 @@ function tableHasColumn(db, columnName) {
524
580
  const columns = db.all(`PRAGMA table_info(jobs)`);
525
581
  return columns.some((column) => column.name === columnName);
526
582
  }
583
+ function mcpTableHasColumn(db, columnName) {
584
+ return db.all("PRAGMA table_info(mcp_tasks)").some((column) => column.name === columnName);
585
+ }
527
586
  function isSqliteConstraintError(error) {
528
587
  const message = formatError(error).toLowerCase();
529
588
  return message.includes("unique constraint") || message.includes("constraint failed");
@@ -30,6 +30,9 @@ export function normalizeJobError(error) {
30
30
  };
31
31
  }
32
32
  export async function markJobFailed(store, paths, job, error, summary = "Job failed.", options) {
33
+ const current = store.getJob(job.job_id) ?? job;
34
+ if (current.status !== "running")
35
+ return current;
33
36
  if (options?.partialOutput) {
34
37
  const label = job.command_type[0].toUpperCase() + job.command_type.slice(1);
35
38
  summary = `${label} timed out after ${formatBudgetDuration(options.partialOutput.budgetMs)}; partial output captured (${options.partialOutput.text.trim().length} chars).`;
@@ -55,6 +58,9 @@ export async function markJobFailed(store, paths, job, error, summary = "Job fai
55
58
  }) ?? artifactJob);
56
59
  }
57
60
  export async function markJobCancelled(store, paths, job, summary, error, options) {
61
+ const current = store.getJob(job.job_id) ?? job;
62
+ if (current.status !== "running")
63
+ return current;
58
64
  const normalized = error ? normalizeJobError(error) : null;
59
65
  const phase = options?.phase;
60
66
  const artifactJob = {
@@ -77,6 +83,8 @@ export async function markJobCancelled(store, paths, job, summary, error, option
77
83
  }
78
84
  export async function sweepStaleJobs(store, paths) {
79
85
  for (const job of store.listRunningJobsWithProcessHints()) {
86
+ if (store.getMcpTask(job.job_id))
87
+ continue;
80
88
  if (!isStaleEnoughToSweep(job)) {
81
89
  continue;
82
90
  }
@@ -109,7 +117,7 @@ export async function sweepStaleJobs(store, paths) {
109
117
  await markJobFailed(store, paths, job, new RuntimeError("FOREGROUND_PROCESS_DISAPPEARED", describeMissingForegroundProcess(job, companionMissing, kimiMissing), "jobs.sweep"), "Foreground Kimi job disappeared before reporting a terminal state.", job.command_type === "ask" || job.command_type === "rescue" ? { phase: "failed" } : undefined);
110
118
  }
111
119
  }
112
- const STALE_SWEEP_GRACE_MS = 15_000;
120
+ export const STALE_SWEEP_GRACE_MS = 15_000;
113
121
  function isStaleEnoughToSweep(job) {
114
122
  const updatedAt = Date.parse(job.updated_at);
115
123
  return Number.isFinite(updatedAt) && Date.now() - updatedAt >= STALE_SWEEP_GRACE_MS;
@@ -41,6 +41,7 @@ export const NATIVE_V2_CERTIFIED_VERSIONS = Object.freeze([
41
41
  "2.2.0",
42
42
  "2.3.0",
43
43
  "2.3.1",
44
+ "2.3.2",
44
45
  ]);
45
46
  /** Per-operation native-v2 certification. One green operation never certifies another. */
46
47
  export const NATIVE_V2_CERTIFIED = new Map([
@@ -0,0 +1,58 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { RuntimeError } from "../errors.js";
3
+ export async function assertMcpJobBinding(task, job) {
4
+ if (!job)
5
+ throw bindingError(task, "The supervised job row does not exist.");
6
+ if (job.job_id !== task.task_id)
7
+ throw bindingError(task, "Task and job IDs differ.");
8
+ if (job.operation_kind !== task.operation_kind)
9
+ throw bindingError(task, "Task and job operation kinds differ.");
10
+ if (job.repo_id !== task.repo_id)
11
+ throw bindingError(task, "Task and job repositories differ.");
12
+ if (job.background)
13
+ throw bindingError(task, "Supervised MCP jobs must retain foreground companion ownership.");
14
+ if (job.status === "running" && (task.companion_pid === null || job.pid !== task.companion_pid)) {
15
+ throw bindingError(task, "Task and job companion process IDs differ or are missing.");
16
+ }
17
+ let jobCwd;
18
+ try {
19
+ jobCwd = await realpath(job.cwd);
20
+ }
21
+ catch (error) {
22
+ throw bindingError(task, "The job workspace cannot be resolved.", error);
23
+ }
24
+ if (jobCwd !== task.workspace_cwd)
25
+ throw bindingError(task, "Task and job workspaces differ.");
26
+ return job;
27
+ }
28
+ export function mcpCompanionIdentity(task) {
29
+ return parseIdentity(task.companion_identity, task.companion_pid, task, "companion");
30
+ }
31
+ export function mcpKimiIdentity(task, job) {
32
+ return parseIdentity(task.kimi_identity, job.kimi_pid, task, "Kimi");
33
+ }
34
+ function parseIdentity(serialized, expectedPid, task, label) {
35
+ if (serialized === null)
36
+ return null;
37
+ try {
38
+ const value = JSON.parse(serialized);
39
+ if (!Number.isInteger(value.pid) || (value.pid ?? 0) <= 0 ||
40
+ !Number.isInteger(value.processGroupId) ||
41
+ typeof value.startToken !== "string" || value.startToken.length === 0 ||
42
+ typeof value.state !== "string") {
43
+ throw new Error("invalid identity shape");
44
+ }
45
+ if (expectedPid !== null && expectedPid !== value.pid)
46
+ throw new Error("identity and recorded PID differ");
47
+ return value;
48
+ }
49
+ catch (error) {
50
+ throw bindingError(task, `The ${label} process identity is invalid.`, error);
51
+ }
52
+ }
53
+ function bindingError(task, message, cause) {
54
+ return new RuntimeError("MCP_JOB_BINDING_FAILED", message, "mcp.binding", {
55
+ ...(cause instanceof Error ? { cause } : {}),
56
+ details: { job_id: task.task_id, operation_kind: task.operation_kind },
57
+ });
58
+ }