kimi-plugin-cc 2.3.1 → 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 (61) 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 +10 -0
  5. package/README.md +57 -5
  6. package/dist/cli-client.js +11 -1
  7. package/dist/commands/ask.js +4 -2
  8. package/dist/commands/cancel.js +43 -4
  9. package/dist/commands/pursue.js +3 -2
  10. package/dist/commands/rescue.js +3 -2
  11. package/dist/commands/review-gate.js +2 -4
  12. package/dist/commands/review.js +4 -2
  13. package/dist/commands/swarm.js +4 -3
  14. package/dist/companion.js +13 -40
  15. package/dist/help.js +2 -0
  16. package/dist/job-id.js +12 -0
  17. package/dist/job-store.js +59 -0
  18. package/dist/jobs.js +9 -1
  19. package/dist/mcp/binding.js +58 -0
  20. package/dist/mcp/job-control.js +345 -0
  21. package/dist/mcp/launcher.js +257 -0
  22. package/dist/mcp/operations.js +301 -0
  23. package/dist/mcp/protocol.js +275 -0
  24. package/dist/mcp/server.js +8 -0
  25. package/dist/mcp/stdio.js +78 -0
  26. package/dist/mcp/supervised-child.js +15 -0
  27. package/dist/mcp/tool-result.js +7 -0
  28. package/dist/mcp/workspace.js +34 -0
  29. package/dist/model-command.js +32 -0
  30. package/dist/version.js +1 -1
  31. package/docs/migration.md +2 -2
  32. package/docs/registry-releases.md +6 -8
  33. package/docs/safety.md +16 -2
  34. package/package.json +7 -3
  35. package/plugins/kimi-codex/.codex-plugin/plugin.json +7 -4
  36. package/plugins/kimi-codex/.mcp.json +28 -0
  37. package/plugins/kimi-codex/dist/cli-client.js +11 -1
  38. package/plugins/kimi-codex/dist/commands/ask.js +4 -2
  39. package/plugins/kimi-codex/dist/commands/cancel.js +43 -4
  40. package/plugins/kimi-codex/dist/commands/pursue.js +3 -2
  41. package/plugins/kimi-codex/dist/commands/rescue.js +3 -2
  42. package/plugins/kimi-codex/dist/commands/review-gate.js +2 -4
  43. package/plugins/kimi-codex/dist/commands/review.js +4 -2
  44. package/plugins/kimi-codex/dist/commands/swarm.js +4 -3
  45. package/plugins/kimi-codex/dist/companion.js +13 -40
  46. package/plugins/kimi-codex/dist/help.js +2 -0
  47. package/plugins/kimi-codex/dist/job-id.js +12 -0
  48. package/plugins/kimi-codex/dist/job-store.js +59 -0
  49. package/plugins/kimi-codex/dist/jobs.js +9 -1
  50. package/plugins/kimi-codex/dist/mcp/binding.js +58 -0
  51. package/plugins/kimi-codex/dist/mcp/job-control.js +345 -0
  52. package/plugins/kimi-codex/dist/mcp/launcher.js +257 -0
  53. package/plugins/kimi-codex/dist/mcp/operations.js +301 -0
  54. package/plugins/kimi-codex/dist/mcp/protocol.js +275 -0
  55. package/plugins/kimi-codex/dist/mcp/server.js +8 -0
  56. package/plugins/kimi-codex/dist/mcp/stdio.js +78 -0
  57. package/plugins/kimi-codex/dist/mcp/supervised-child.js +15 -0
  58. package/plugins/kimi-codex/dist/mcp/tool-result.js +7 -0
  59. package/plugins/kimi-codex/dist/mcp/workspace.js +34 -0
  60. package/plugins/kimi-codex/dist/model-command.js +32 -0
  61. package/plugins/kimi-codex/dist/version.js +1 -1
@@ -305,7 +305,7 @@ export async function runCliPrompt(opts) {
305
305
  args,
306
306
  command_label: opts.commandLabel ?? null,
307
307
  });
308
- return await new Promise((resolve, reject) => {
308
+ return await new Promise(async (resolve, reject) => {
309
309
  let aborted = false;
310
310
  let cancellationTeardown;
311
311
  let settlementStarted = false;
@@ -536,6 +536,16 @@ export async function runCliPrompt(opts) {
536
536
  if (opts.signal?.aborted === true) {
537
537
  onAbort();
538
538
  }
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
+ }
539
549
  earlyErrorHandled = true;
540
550
  child.on("error", (err) => {
541
551
  appendLogLine({ event: "process_error", message: formatError(err) });
@@ -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";
@@ -55,7 +55,7 @@ export async function runAsk(argv, context) {
55
55
  const store = new JobStore(paths);
56
56
  try {
57
57
  await sweepStaleJobs(store, paths);
58
- const jobId = randomUUID();
58
+ const jobId = allocateJobId(context);
59
59
  const sessionResolution = await resolveAskSession(store, repoIdentity.repoId, parsed.fresh, parsed.resume, parsed.resumeTarget);
60
60
  const askPrompt = buildAskPrompt(parsed.prompt, sessionResolution.reusedSession);
61
61
  const executionPlan = await prepareKimiExecutionPlan({
@@ -152,6 +152,7 @@ export async function executeAskJob(jobId, prompt, context, options) {
152
152
  }
153
153
  handlers = createCliCancellationHandlers();
154
154
  const executionPlan = executionPlanFromPersisted(job);
155
+ store.updateRunningJob(job.job_id, { phase: "turn-running" });
155
156
  // Re-validate resume lineage from the store before spawning: a detached
156
157
  // worker (or a forged queued row) reloads the persisted plan and would
157
158
  // otherwise hand kimi_session_id to the spawn without the dispatch-time
@@ -174,6 +175,7 @@ export async function executeAskJob(jobId, prompt, context, options) {
174
175
  thinkingEffort: job.thinking_effort ?? undefined,
175
176
  resumeSessionId: job.kimi_session_id ?? undefined,
176
177
  logPath: job.stream_log_path,
178
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
177
179
  signal: handlers.signal,
178
180
  }, job.budget_ms ?? KIMI_ASK_PROMPT_TIMEOUT_MS, "ask.prompt");
179
181
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
@@ -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,6 +32,10 @@ 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
  }
@@ -53,6 +58,7 @@ export async function runCancel(argv, context) {
53
58
  catch (error) {
54
59
  preMarkError = error;
55
60
  }
61
+ await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity);
56
62
  signalJobProcesses(job, "SIGTERM");
57
63
  await sleep(1_000);
58
64
  if (hasLiveRecordedProcess(job)) {
@@ -67,6 +73,8 @@ export async function runCancel(argv, context) {
67
73
  message: "review_gate cancelled; SIGTERM→SIGKILL escalation completed.",
68
74
  }, null, 2)}\n`;
69
75
  }
76
+ await assertMcpCompanionIdentity(job, context.mcpCompanionIdentity);
77
+ await assertMcpKimiIdentity(job, context.mcpKimiIdentity);
70
78
  signalJobProcesses(job, "SIGTERM");
71
79
  const cancelled = await waitForCancellation(paths, job.job_id);
72
80
  if (cancelled) {
@@ -76,7 +84,11 @@ export async function runCancel(argv, context) {
76
84
  message: "Cancellation completed.",
77
85
  }, null, 2)}\n`;
78
86
  }
79
- 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
+ }
80
92
  await withJobStore(paths, async (forcedStore) => {
81
93
  const current = forcedStore.getJob(job.job_id);
82
94
  if (current?.status === "running") {
@@ -93,11 +105,38 @@ export async function runCancel(argv, context) {
93
105
  store.close();
94
106
  }
95
107
  }
96
- 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 }) {
97
136
  const pids = new Set();
98
- if (job.pid !== null)
137
+ if (include.companion && job.pid !== null)
99
138
  pids.add(job.pid);
100
- if (job.kimi_pid !== null)
139
+ if (include.kimi && job.kimi_pid !== null)
101
140
  pids.add(job.kimi_pid);
102
141
  for (const pid of pids) {
103
142
  try {
@@ -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";
@@ -122,7 +122,7 @@ export async function runPursue(argv, context) {
122
122
  env: context.env,
123
123
  });
124
124
  const prompt = buildGoalPrompt(objective, parsed.turns);
125
- const jobId = randomUUID();
125
+ const jobId = allocateJobId(context);
126
126
  const logPath = path.join(paths.logsDir, `pursue-${jobId}.jsonl`);
127
127
  const job = store.createJob({
128
128
  job_id: jobId,
@@ -226,6 +226,7 @@ async function executePursueJob(jobId, prompt, objective, budgetMs, context) {
226
226
  model: job.model ?? undefined,
227
227
  thinkingEffort: job.thinking_effort ?? undefined,
228
228
  logPath: job.stream_log_path,
229
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
229
230
  signal: handlers.signal,
230
231
  }, budgetMs, "pursue.prompt");
231
232
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
@@ -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";
@@ -61,7 +61,7 @@ export async function runRescue(argv, context) {
61
61
  resumeSessionId: sessionResolution.kimiSessionId ?? undefined,
62
62
  resumeSource: sessionResolution.sourceJob,
63
63
  });
64
- const jobId = randomUUID();
64
+ const jobId = allocateJobId(context);
65
65
  const logPath = path.join(paths.logsDir, `rescue-${jobId}.jsonl`);
66
66
  const job = store.createJob({
67
67
  job_id: jobId,
@@ -192,6 +192,7 @@ export async function executeRescueJob(jobId, prompt, context, options) {
192
192
  thinkingEffort: job.thinking_effort ?? undefined,
193
193
  resumeSessionId: job.kimi_session_id ?? undefined,
194
194
  logPath: job.stream_log_path,
195
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
195
196
  signal: handlers.signal,
196
197
  }, job.budget_ms ?? KIMI_RESCUE_PROMPT_TIMEOUT_MS, "rescue.prompt");
197
198
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
@@ -166,10 +166,7 @@ async function executeReviewGate(payload, assistantMessage, context) {
166
166
  try {
167
167
  const activeStore = store;
168
168
  // runCliPromptWithBudget ties the 8 s timeout to an AbortController
169
- // that kills the kimi child on expiry — review_gate runs inside
170
- // Claude Code's Stop hook, so a runaway kimi after timeout would
171
- // hold model tokens with no way for /kimi:cancel to reach it
172
- // (the SQLite row's kimi_pid is null). See reports/17 and 18.
169
+ // that kills the kimi child on expiry and records its PID at spawn.
173
170
  const result = await runCliPromptWithBudget({
174
171
  cwd: payload.cwd,
175
172
  env: context.env,
@@ -182,6 +179,7 @@ async function executeReviewGate(payload, assistantMessage, context) {
182
179
  // Requested via env; provider/model may ignore it. The 8s budget still applies.
183
180
  thinkingEffort,
184
181
  logPath,
182
+ onSpawn: (pid) => { activeStore.updateRunningJob(job.job_id, { kimi_pid: pid }); },
185
183
  }, KIMI_REVIEW_GATE_TIMEOUT_MS, "review_gate.runtime");
186
184
  activeStore.updateRunningJob(job.job_id, observedExecutionFields(result));
187
185
  assertCliResultSuccess(result, "review_gate.runtime");
@@ -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 process from "node:process";
4
4
  import { createCliCancellationHandlers } from "../cli-cancellation.js";
@@ -38,7 +38,7 @@ export async function runReview(argv, context, commandType) {
38
38
  env: context.env,
39
39
  });
40
40
  const store = new JobStore(paths);
41
- const jobId = randomUUID();
41
+ const jobId = allocateJobId(context);
42
42
  // Leave kimi_session_id NULL until kimi announces an actual ID; never invent
43
43
  // one that no on-disk session matches.
44
44
  const reviewSessionId = null;
@@ -75,6 +75,7 @@ export async function runReview(argv, context, commandType) {
75
75
  const reviewConfig = getManagedCommandConfig(commandType);
76
76
  const cancel = reviewConfig.cancellation;
77
77
  const handlers = createCliCancellationHandlers();
78
+ store.updateRunningJob(job.job_id, { phase: "turn-running" });
78
79
  try {
79
80
  const result = await runCliPromptWithBudget({
80
81
  cwd: context.cwd,
@@ -87,6 +88,7 @@ export async function runReview(argv, context, commandType) {
87
88
  model: parsed.model,
88
89
  thinkingEffort: parsed.thinkingEffort,
89
90
  logPath,
91
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
90
92
  signal: handlers.signal,
91
93
  }, job.budget_ms ?? KIMI_REVIEW_PROMPT_TIMEOUT_MS, `${commandType}.prompt`);
92
94
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
@@ -1,4 +1,4 @@
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";
@@ -180,7 +180,7 @@ async function runReadSwarm(parsed, objective, context) {
180
180
  env: context.env,
181
181
  });
182
182
  const prompt = buildSwarmPrompt(objective, parsed.cap);
183
- const jobId = randomUUID();
183
+ const jobId = allocateJobId(context);
184
184
  const logPath = path.join(paths.logsDir, `swarm-${jobId}.jsonl`);
185
185
  const job = store.createJob({
186
186
  job_id: jobId,
@@ -257,7 +257,7 @@ async function runWriteSwarm(parsed, objective, context) {
257
257
  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
258
  }
259
259
  const prompt = buildSwarmWritePrompt(objective, parsed.cap);
260
- const jobId = randomUUID();
260
+ const jobId = allocateJobId(context);
261
261
  const logPath = path.join(paths.logsDir, `swarm-write-${jobId}.jsonl`);
262
262
  // Namespace worktrees per repo so the cross-repo sweep below only ever
263
263
  // touches THIS repo's subtree (the worktreesDir is a single global dir shared
@@ -493,6 +493,7 @@ writeMode) {
493
493
  model: job.model ?? undefined,
494
494
  thinkingEffort: job.thinking_effort ?? undefined,
495
495
  logPath: job.stream_log_path,
496
+ onSpawn: (pid) => { store.updateRunningJob(job.job_id, { kimi_pid: pid }); },
496
497
  signal: handlers.signal,
497
498
  }, budgetMs, "swarm.prompt");
498
499
  store.updateRunningJob(job.job_id, observedExecutionFields(result));
@@ -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
  }
@@ -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;
@@ -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
+ }