cookbook-bridge 0.1.11 → 0.1.12

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.
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Changelog
4
4
 
5
+ **0.1.12** (2026-09-02)
6
+ - Kimi Code is the fourth agent: `connect` finds `kimi`, mints a "Kimi" token and writes `~/.kimi-code/mcp.json` (owner-only, other servers kept). Runs use `kimi -p ... --output-format stream-json`; the board gets live text and the work log, resume by session id, and a duration-only receipt (kimi reports no token counts).
7
+ - A headless kimi run approves every tool and has no `--allowedTools` flag, so the Bridge turns the config's `--allowedTools` into a per-run agent file (`--agent-file`, a 0600 temp file) whose tools allowlist is exactly that list. `doctor` fails a Kimi agent that has no `--allowedTools`.
8
+ - Billing protection also hides `KIMI_API_KEY`, `MOONSHOT_API_KEY` and `KIMI_MODEL_API_KEY`.
9
+ - `doctor` gained Kimi rows: version, login (config.toml providers or OAuth credentials), the MCP file, and the two flags a headless run needs.
10
+
5
11
  **0.1.11** (2026-09-02)
6
12
  - The Bridge has a home: `~/.cookbook/config.json` (file 0600, folder 0700) for every command. `bridge.state.json`, `local.json` and `bridge.log` sit next to it. Before, the config lived next to `bridge.mjs`, so every `npx cookbook-bridge@latest` landed in a fresh cache folder and lost it. A config found next to `bridge.mjs` is copied to the home once (the old file stays) and the move is announced in one line. `--config <path>` and `COOKBOOK_CONFIG` still win.
7
13
  - `connect` runs the Bridge right after the approval (pass `--no-run` to stop at "connected"). With no agent CLI found it prints the doctor instead.
@@ -25,7 +31,7 @@
25
31
  - Set `COOKBOOK_NO_BROWSER=1` to stop `login`/`connect` from opening a browser (the URL is still printed).
26
32
 
27
33
 
28
- Runs your **own AI agents** (Claude Code, Codex, Gemini) on **your own subscriptions**,
34
+ Runs your **own AI agents** (Claude Code, Codex, Gemini, Kimi Code) on **your own subscriptions**,
29
35
  against your Cookbook workspaces — so tasks on the board get done by your agents
30
36
  automatically, on your machine, with **no API credits**.
31
37
 
@@ -45,7 +51,7 @@ Trust model: your Cookbook's `/security` page.
45
51
 
46
52
  ```bash
47
53
  npx cookbook-bridge@latest connect # ONE approval connects the Bridge AND every installed
48
- # agent CLI (claude, codex, agy, openclaw), each with its
54
+ # agent CLI (claude, codex, agy, kimi, openclaw), each with its
49
55
  # own attributed token, then RUNS the Bridge. Leave it open.
50
56
  ```
51
57
 
@@ -89,7 +95,7 @@ attribution label, "Claude · via you"), then configures each CLI via its own `m
89
95
  **Account → Tokens** page.
90
96
 
91
97
  Requires **Node 18+** (built-in `fetch`, no npm install) and at least one agent CLI
92
- installed and logged in (`claude`, `agy` — the Antigravity CLI for Gemini — or the Codex app). Each agent must also be
98
+ installed and logged in (`claude`, `agy` — the Antigravity CLI for Gemini — `kimi`, or the Codex app). Each agent must also be
93
99
  connected to Cookbook over MCP — that's how it completes tasks. Run `doctor`; it tells
94
100
  you exactly which parts are ready and how to fix the rest.
95
101
 
@@ -143,6 +149,8 @@ local files are behind.
143
149
  "command": ["claude", "-p", "{prompt}", "--allowedTools", "mcp__cookbook__*", "--output-format", "json"] },
144
150
  { "name": "Gemini", "match": ["gemini"], "enabled": true,
145
151
  "command": ["agy", "-p", "{prompt}", "--sandbox", "--print-timeout", "3600s"] },
152
+ { "name": "Kimi", "match": ["kimi"], "enabled": true,
153
+ "command": ["kimi", "-p", "{prompt}", "--allowedTools", "mcp__cookbook__*", "--output-format", "stream-json"] },
146
154
  { "name": "Codex", "match": ["codex", "chatgpt"], "enabled": false, "runner": "app-server",
147
155
  "command": ["/Applications/Codex.app/Contents/Resources/codex"] }
148
156
  ]
@@ -158,6 +166,13 @@ local files are behind.
158
166
  usual cause — `doctor` checks it.
159
167
  - **Codex (ChatGPT)** runs through `codex app-server` (its headless `exec` can't call
160
168
  MCP tools); see `_setup` in `config.example.json` for the 3-step enable.
169
+ - **Kimi Code** runs `kimi -p` with `--output-format stream-json` (live text, work log,
170
+ session resume; no token counts, so the receipt is duration only). Its headless mode
171
+ approves every tool and has no `--allowedTools` flag, so the Bridge turns that value
172
+ into a per-run agent file (`--agent-file`) whose `tools:` allowlist is exactly the
173
+ list. Keep it on the command; `doctor` fails a Kimi agent without it. `connect`
174
+ writes `~/.kimi-code/mcp.json` (kimi has no `mcp add`). Kimi does not stream its
175
+ thinking, so a long think looks like silence to `livenessTimeoutSeconds`.
161
176
  - `"default"`: which agent takes tasks assigned to **any**.
162
177
 
163
178
  ## What rides into (and out of) every run
@@ -180,9 +195,11 @@ you opt an agent in, and only tasks explicitly posted as goals are ever eligible
180
195
 
181
196
  ## Safety rails (on by default)
182
197
 
183
- - **Billing protection**: vendor API keys (`ANTHROPIC_API_KEY` etc.) are hidden from
184
- agent processes, so a task can never silently bill your API account instead of your
185
- subscription. Opt out with `"allowApiKeyBilling": true`.
198
+ - **Billing protection**: vendor API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
199
+ `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `KIMI_API_KEY`, `MOONSHOT_API_KEY`,
200
+ `KIMI_MODEL_API_KEY`) are hidden from agent processes, so a task can never silently
201
+ bill your API account instead of your subscription. Opt out with
202
+ `"allowApiKeyBilling": true`.
186
203
  - **Vulnerable-version gate**: gemini-cli below 0.39.1 (the CVSS-10.0 RCE fix) is refused; agy below 1.1.1 (headless MCP) is refused.
187
204
  - **Who can use your agents**: `"acceptFrom": "anyone"` (default) or a list of member
188
205
  names (e.g. `["dp", "pierre"]`); per-agent overrides supported. Plus the per-person
package/bridge.mjs CHANGED
@@ -33,7 +33,7 @@ import os from "node:os";
33
33
  import path from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
  import { spawn } from "node:child_process";
36
- import { callsFromStreamLine, foldCallEvent, wireCalls } from "./live.mjs";
36
+ import { callsFromStreamLine, foldCallEvent, wireCalls, shortTool, argFor } from "./live.mjs";
37
37
  import { planFromStreamLine, notePlan, planLine } from "./plan.mjs";
38
38
 
39
39
  // Node version guard: below 18 there is no global fetch and none of this runs. One
@@ -59,6 +59,7 @@ import { resolveAgentForTask } from "./chef.mjs";
59
59
  import { locateConfig, cli, updateLine, configHome } from "./update.mjs";
60
60
  let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, agentsQuery;
61
61
  let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig;
62
+ let isKimiCommand, kimiFromLine, kimiResultEnvelope, kimiCommand, kimiLoginState, kimiMcpState, checkKimiVersion;
62
63
  let extractUsage, displayText;
63
64
  let volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities;
64
65
  let buildPrompt, buildThreadFollowUpPrompt;
@@ -75,7 +76,8 @@ async function loadRuntime() {
75
76
  ({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
76
77
  ({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult, agentsQuery } = await import("./cookbook.mjs"));
77
78
  ({ serveCalls, describeCall, hostingMode, which: whichExec, argvForSpawn, redact: redactText, resolveCmdShim, killTree } = await import("./hands.mjs"));
78
- ({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig } = await import("./harden.mjs"));
79
+ ({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig,
80
+ isKimiCommand, kimiFromLine, kimiResultEnvelope, kimiCommand, kimiLoginState, kimiMcpState, checkKimiVersion } = await import("./harden.mjs"));
79
81
  ({ extractUsage, displayText } = await import("./usage.mjs"));
80
82
  ({ volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities } = await import("./volunteer.mjs"));
81
83
  ({ buildPrompt, buildThreadFollowUpPrompt } = await import("./prompt.mjs"));
@@ -123,6 +125,7 @@ function ensureAgentPath() {
123
125
  if (home) {
124
126
  dirs.push(path.join(home, ".claude/local")); // claude CLI local install
125
127
  dirs.push(path.join(home, ".bun/bin"), path.join(home, ".local/bin"));
128
+ dirs.push(path.join(process.env.KIMI_CODE_HOME || path.join(home, ".kimi-code"), "bin")); // kimi's install-script binary
126
129
  try {
127
130
  const base = path.join(home, ".nvm/versions/node");
128
131
  const vers = fs.readdirSync(base).sort();
@@ -405,20 +408,30 @@ export function shouldKill({ streaming, startedAt, lastActivityAt, now, liveness
405
408
  export function withModel(command, model) {
406
409
  if (!Array.isArray(command) || !model) return command;
407
410
  const base = String(command[0] ?? "").split(/[\\/]/).pop().toLowerCase();
408
- if (base !== "claude") return command;
411
+ // Kimi: `-m <alias>` (an alias from ~/.kimi-code/config.toml). Same replace rule.
412
+ const kimi = base === "kimi" || base === "kimi.exe";
413
+ if (base !== "claude" && !kimi) return command;
414
+ const flags = kimi ? ["-m", "--model"] : ["--model"];
409
415
  const out = [];
410
416
  for (let i = 0; i < command.length; i++) {
411
- if (command[i] === "--model") { i++; continue; }
417
+ if (flags.includes(command[i])) { i++; continue; }
412
418
  if (String(command[i]).startsWith("--model=")) continue;
413
419
  out.push(command[i]);
414
420
  }
415
- out.push("--model", model);
421
+ out.push(kimi ? "-m" : "--model", model);
416
422
  return out;
417
423
  }
418
424
 
419
425
  export function resumeCommand(command, sessionId) {
420
426
  if (!Array.isArray(command) || !sessionId) return { command, resumed: false };
421
427
  const base = String(command[0] ?? "").split(/[\\/]/).pop().toLowerCase();
428
+ if (base === "kimi" || base === "kimi.exe") {
429
+ // Kimi resumes with `-S <id>` (the id its session.resume_hint line carried).
430
+ // kimiCommand drops the --allowedTools jail on a resume: the session keeps
431
+ // the agent file it was created with, and kimi refuses --agent-file here.
432
+ if (command.some((a) => a === "-S" || a === "--session" || a === "-r" || a === "--resume")) return { command, resumed: true };
433
+ return { command: [command[0], "-S", sessionId, ...command.slice(1)], resumed: true };
434
+ }
422
435
  if (base !== "claude") return { command, resumed: false };
423
436
  if (command.includes("--resume")) return { command, resumed: true };
424
437
  return { command: [command[0], "--resume", sessionId, ...command.slice(1)], resumed: true };
@@ -438,7 +451,14 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
438
451
  // The bearer token leaves argv here: an inline --mcp-config JSON becomes a 0600
439
452
  // temp file (harden.mjs materializeMcpConfig), removed when the run ends.
440
453
  const mat = materializeMcpConfig ? materializeMcpConfig(command) : { command, cleanup: () => {} };
441
- const [cmd, ...rawArgs] = mat.command;
454
+ // KIMI JAIL: `--allowedTools X` becomes a per-run agent file (0600) whose tools
455
+ // allowlist is X, because kimi's headless mode approves every tool and has no
456
+ // such flag (harden.mjs kimiCommand). Dropped on a resume, where the session
457
+ // already carries it. Identity for every other CLI.
458
+ const jail = kimiCommand ? kimiCommand(mat.command) : { command: mat.command, cleanup: () => {} };
459
+ const kimi = isKimiCommand ? isKimiCommand(jail.command) : false;
460
+ const cleanupRun = () => { jail.cleanup(); mat.cleanup(); };
461
+ const [cmd, ...rawArgs] = jail.command;
442
462
  // PROMPT DELIVERY. claude reads the prompt from stdin when `-p` has no
443
463
  // positional prompt (synthesis.mjs relies on the same). That keeps workspace
444
464
  // text out of argv: no argument-length ceiling, and on Windows no cmd.exe
@@ -459,7 +479,7 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
459
479
  // or refuse with the fix (hands.mjs resolveCmdShim).
460
480
  const script = resolveCmdShim ? resolveCmdShim(bare) : null;
461
481
  if (!script) {
462
- mat.cleanup();
482
+ cleanupRun();
463
483
  reject(new Error(`refusing to pass a prompt through the ${path.basename(bare)} shell shim on Windows (cmd.exe quoting is not safe for workspace text). Point this agent's command at the CLI's .js entry or its real executable instead.`));
464
484
  return;
465
485
  }
@@ -476,7 +496,7 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
476
496
  ...(agent.cwd ? { cwd: agent.cwd } : {}),
477
497
  });
478
498
  } catch (e) {
479
- mat.cleanup();
499
+ cleanupRun();
480
500
  reject(new Error(`could not launch \`${cmd}\`: ${e.message}`));
481
501
  return;
482
502
  }
@@ -499,13 +519,19 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
499
519
  let partialText = "";
500
520
  // Live CALLS (show the work): tool_use/tool_result folded into a capped list.
501
521
  let calls = [];
522
+ // Kimi: the last assistant text is the run's answer (no result line exists);
523
+ // it becomes a claude-shaped envelope on close (harden.mjs kimiResultEnvelope).
524
+ let kimiText = "";
525
+ let kimiTurns = 0;
502
526
  const liveText = () => {
503
527
  const full = partialText ? `${turnsText}${turnsText ? "\n\n" : ""}${partialText}` : turnsText;
504
528
  return full.length > LIVE_TEXT_CAP ? "…" + full.slice(-LIVE_TEXT_CAP) : full;
505
529
  };
506
530
  const emit = () => {
507
531
  const text = liveText();
508
- if (!onProgress || (acc.input_tokens === 0 && acc.output_tokens === 0 && !text)) return;
532
+ // A tick with only calls (a run that reads before it speaks; every kimi run,
533
+ // whose stream carries no token counts) is still worth showing.
534
+ if (!onProgress || (acc.input_tokens === 0 && acc.output_tokens === 0 && !text && !calls.length)) return;
509
535
  lastEmit = Date.now();
510
536
  // session_ref rides every tick once known: the server-visible resume handle that
511
537
  // lets a Composer-thread follow-up continue THIS conversation (0064), surviving
@@ -541,6 +567,24 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
541
567
  // text throttle (still ≥300ms apart so a burst of reads is one tick).
542
568
  let touched = false;
543
569
  for (const ev of callsFromStreamLine(line)) { calls = foldCallEvent(calls, ev); touched = true; }
570
+ if (kimi) {
571
+ // Kimi's lines are keyed by `role` (claude's by `type`), so the claude
572
+ // parsers above ignore them and this is the only reader.
573
+ const kev = kimiFromLine(line);
574
+ if (kev) {
575
+ if (kev.sessionId && !sessionId) sessionId = kev.sessionId;
576
+ if (kev.text) {
577
+ turnsText += (turnsText ? "\n\n" : "") + kev.text;
578
+ kimiText = kev.text;
579
+ kimiTurns++;
580
+ }
581
+ for (const c of kev.calls) {
582
+ calls = foldCallEvent(calls, c.kind === "call" ? { kind: "call", id: c.id, name: shortTool(c.name), arg: argFor(c.input) } : c);
583
+ touched = true;
584
+ }
585
+ if (kev.retry) log(` ↳ ${agent.name}: kimi is retrying its API (${kev.retry.attempt}/${kev.retry.max}): ${kev.retry.error}`);
586
+ }
587
+ }
544
588
  // The CLI also says where the member's plan stands (claude: rate_limit_event).
545
589
  // Remembered per vendor; the next heartbeat carries it (bridge/plan.mjs).
546
590
  const plan = planFromStreamLine(line);
@@ -577,15 +621,18 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
577
621
 
578
622
  child.on("error", (e) => {
579
623
  clearInterval(watchdog);
580
- mat.cleanup();
624
+ cleanupRun();
581
625
  reject(new Error(`could not launch \`${cmd}\` (is it installed + on PATH?): ${e.message}`));
582
626
  });
583
627
  child.on("close", (code) => {
584
628
  clearInterval(watchdog);
585
- mat.cleanup();
629
+ cleanupRun();
586
630
  // In streaming mode, hand back the final result line (json-mode-identical); fall
587
631
  // back to the raw buffer if the run died before emitting one.
588
- resolve({ code, out: streaming ? (resultLine || lineBuf || out) : out, err, sessionId });
632
+ const kimiOut = kimi && streaming
633
+ ? kimiResultEnvelope({ text: kimiText, sessionId, durationMs: Date.now() - startedAt, code, err, numTurns: kimiTurns })
634
+ : null;
635
+ resolve({ code, out: kimiOut ?? (streaming ? (resultLine || lineBuf || out) : out), err, sessionId });
589
636
  });
590
637
  });
591
638
  }
@@ -694,6 +741,11 @@ function failureHint(result) {
694
741
  const tail = tailSrc.split("\n").slice(-2).join(" ").slice(0, 240);
695
742
  if (infra.includes("not logged in") || infra.includes("please log in"))
696
743
  return "the agent CLI isn't logged in → run `claude auth login`";
744
+ // Kimi's own error strings (stderr: "error: failed to run prompt: provider.connection_error: …").
745
+ if (infra.includes("provider.connection_error") || infra.includes("connection error"))
746
+ return "the agent CLI can't reach its API (a network or DNS block on the vendor's hosts) → check the connection, then try `kimi -p hi` by hand";
747
+ if (infra.includes("no provider") || infra.includes("default_model") || infra.includes("no model configured"))
748
+ return "kimi has no provider configured → run `kimi login`";
697
749
  if (infra.includes("no mcp") || infra.includes("requires authentication"))
698
750
  return `the agent can't reach the Cookbook MCP → run \`${cli("doctor")}\``;
699
751
  if (infra.includes("not allowed") || infra.includes("allowedtools"))
@@ -947,7 +999,8 @@ function detectAgentsForStatus(cfg) {
947
999
  const rows = cfg.agents.map((a) => {
948
1000
  const cmd = Array.isArray(a.command) ? a.command[0] : null;
949
1001
  const binary = resolveBin(cmd);
950
- return { name: a.name, vendor: vendorOf ? vendorOf(a) : "other", binary, found: !!binary, enabled: true, runner: a.runner ?? "cli", configured: true };
1002
+ const vendor = isKimiCommand && isKimiCommand(a.command) ? "kimi" : (vendorOf ? vendorOf(a) : "other");
1003
+ return { name: a.name, vendor, binary, found: !!binary, enabled: true, runner: a.runner ?? "cli", configured: true };
951
1004
  });
952
1005
  try {
953
1006
  for (const cli of detectClis ? detectClis() : []) {
@@ -2550,6 +2603,30 @@ async function doctorReport(args) {
2550
2603
  }
2551
2604
  }
2552
2605
 
2606
+ // Kimi Code: version (no gate), login (config.toml providers / credentials),
2607
+ // the user-level mcp.json the Bridge writes, and the two flags a headless
2608
+ // run needs (the jail and the stream).
2609
+ if (isKimiCommand && isKimiCommand(cmd)) {
2610
+ const { version } = await checkKimiVersion([bin]);
2611
+ if (version) ok(`${agent.name}: kimi ${version}`);
2612
+ else warn(`${agent.name}: couldn't read kimi version`, "run `kimi --version` by hand");
2613
+ const login = kimiLoginState();
2614
+ if (login.loggedIn) ok(`${agent.name}: logged in (${login.providers.length ? `provider ${login.providers.join(", ")}` : `${login.credentials} OAuth credential(s)`})`);
2615
+ else bad(`${agent.name}: no Kimi login found (no provider with a key in ${login.configPath}, nothing under credentials/)`, "run `kimi login` (device code), or `/login` inside `kimi`");
2616
+ const mcp = kimiMcpState({ cookbookUrl: cfg.cookbookUrl });
2617
+ if (mcp.server && mcp.matches && mcp.hasAuth) ok(`${agent.name}: Cookbook MCP configured (${mcp.file})`);
2618
+ else if (mcp.server) warn(`${agent.name}: MCP server 'cookbook' in ${mcp.file} points at ${mcp.url || "(no url)"}${mcp.hasAuth ? "" : " with no Authorization header"}`, `expected ${cfg.cookbookUrl}/api/mcp with a bearer token; re-run \`${cli("connect")}\``);
2619
+ else bad(`${agent.name}: no 'cookbook' server in ${mcp.file}`, `run \`${cli("connect")}\` (kimi has no \`mcp add\`; the Bridge writes this file)`);
2620
+ const cmdArgs = agent.command || [];
2621
+ if (!cmdArgs.includes("--allowedTools")) {
2622
+ bad(`${agent.name}: no --allowedTools in the command; kimi's headless mode auto-approves EVERY tool (Bash included) and has no flag of its own`,
2623
+ `add "--allowedTools", "mcp__cookbook__*" to this agent's command (the Bridge turns it into a per-run agent file)`);
2624
+ } else if (cmdArgs.includes("-S") || cmdArgs.includes("--session") || cmdArgs.includes("-c")) {
2625
+ warn(`${agent.name}: the command resumes a session by hand, so --allowedTools is ignored on that run (kimi binds the agent at session creation)`);
2626
+ }
2627
+ if (!cmdArgs.includes("stream-json")) warn(`${agent.name}: no --output-format stream-json; the board gets no live progress and no work log from this agent`, `add "--output-format", "stream-json" to this agent's command`);
2628
+ }
2629
+
2553
2630
  if (isClaudeCommand && isClaudeCommand(agent.command)) {
2554
2631
  if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
2555
2632
  else warn(`${agent.name}: no per-agent token — runs use the claude CLI's OWN Cookbook login, which may be a different account and inherits stale claude.ai connectors`,
@@ -8,7 +8,7 @@
8
8
  "livenessTimeoutSeconds": 300,
9
9
  "_concurrency": "How many task runs may be in flight at once. Runs launch in parallel up to this cap; the atomic pre-claim keeps every task single-runner.",
10
10
  "maxConcurrentRuns": 3,
11
- "_billing": "Agents run on the CLI subscriptions you already pay for. The Bridge hides ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY from agent processes so a task can never silently bill your API account instead. Set allowApiKeyBilling to true ONLY if you explicitly want API-key billing.",
11
+ "_billing": "Agents run on the CLI subscriptions you already pay for. The Bridge hides ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY / KIMI_API_KEY / MOONSHOT_API_KEY / KIMI_MODEL_API_KEY from agent processes so a task can never silently bill your API account instead. Set allowApiKeyBilling to true ONLY if you explicitly want API-key billing.",
12
12
  "allowApiKeyBilling": false,
13
13
  "_volunteering": "STIGMERGY (off by default): an agent with volunteer:true watches tasks posted as open GOALS (to:'goal' on the board) and may claim ones matching its capabilities — decided by one cheap call to the agent's own CLI, gated by your delegation policy (ask parks it in your approvals inbox), claimed atomically, capped per poll. Flip volunteering:false to kill it globally without touching agents.",
14
14
  "volunteering": true,
@@ -57,6 +57,25 @@
57
57
  "_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause; `npx cookbook-bridge@latest doctor` (tarball: `node bridge/bridge.mjs doctor`) checks it.",
58
58
  "_output": "json output lets the Bridge report what each task cost (tokens/$) back to the board — text works too, you just lose the usage report"
59
59
  },
60
+ {
61
+ "name": "Kimi",
62
+ "match": [
63
+ "kimi",
64
+ "moonshot"
65
+ ],
66
+ "enabled": true,
67
+ "command": [
68
+ "kimi",
69
+ "-p",
70
+ "{prompt}",
71
+ "--allowedTools",
72
+ "mcp__cookbook__*",
73
+ "--output-format",
74
+ "stream-json"
75
+ ],
76
+ "_setup": "Kimi Code CLI (install: curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash, or npm i -g @moonshot-ai/kimi-code; needs 0.39+). Auth: `kimi login` (device code) or /login inside `kimi`; providers live in ~/.kimi-code/config.toml. MCP: `npx cookbook-bridge@latest connect` (tarball: `node bridge/bridge.mjs connect`) writes ~/.kimi-code/mcp.json owner-only (kimi has no `mcp add`). Headless `-p` refuses --yolo/--auto and ALWAYS runs with every tool auto-approved, so --allowedTools is required here: kimi has no such flag, the Bridge turns it into a per-run agent file (--agent-file) whose tools allowlist is exactly this list. Without it, a task's text could run Bash on this machine.",
77
+ "_usage": "kimi's stream-json carries no token counts; the board shows wall-clock duration for its runs. Thinking is not streamed either, so a long think looks like silence: keep livenessTimeoutSeconds generous on a Kimi-heavy board."
78
+ },
60
79
  {
61
80
  "name": "Codex",
62
81
  "match": [
package/device.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * `connect-agents` runs the same flow but asks for one attributed token per agent
14
14
  * CLI found on this machine, then configures each CLI (Claude, Gemini/agy, Codex,
15
- * OpenClaw). The pieces are exported separately so Bridge Local (local.mjs) can run
15
+ * OpenClaw, Kimi). The pieces are exported separately so Bridge Local (local.mjs) can run
16
16
  * the identical flow behind a button: beginDeviceFlow → waitForDeviceToken →
17
17
  * saveLoginConfig → configureClis.
18
18
  *
@@ -31,6 +31,7 @@ import { spawn, spawnSync } from "node:child_process";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import { listWorkspaces } from "./cookbook.mjs";
33
33
  import { which, argvForSpawn } from "./hands.mjs";
34
+ import { kimiHome } from "./harden.mjs";
34
35
  import { locateConfig, writeConfigFile, cli, updateLine, checkForUpdate } from "./update.mjs";
35
36
 
36
37
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -157,6 +158,7 @@ function exampleAgentVendor(a) {
157
158
  if (base === "claude") return "claude";
158
159
  if (base === "agy" || base === "gemini") return "gemini";
159
160
  if (base === "codex") return "codex";
161
+ if (base === "kimi" || base === "kimi.exe") return "kimi";
160
162
  return "other";
161
163
  }
162
164
 
@@ -184,7 +186,7 @@ export function seedConfigFromExample(example, found, { log = () => {} } = {}) {
184
186
  if (a.runner === "app-server" && hit?.path) a.command = [hit.path];
185
187
  }
186
188
  cfg.agents = kept;
187
- const preferred = ["claude", "codex", "gemini", "openclaw"];
189
+ const preferred = ["claude", "codex", "gemini", "kimi", "openclaw"];
188
190
  const enabled = kept.filter((a) => a.enabled !== false);
189
191
  const dflt = preferred.map((v) => enabled.find((a) => exampleAgentVendor(a) === v)).find(Boolean) ?? enabled[0] ?? kept[0] ?? null;
190
192
  if (dflt) cfg.default = dflt.name; else delete cfg.default;
@@ -230,6 +232,36 @@ export function findCodexBinary() {
230
232
  return which("codex");
231
233
  }
232
234
 
235
+ /** Kimi's install script puts the binary at $KIMI_CODE_HOME/bin/kimi (~/.kimi-code/bin),
236
+ * which a GUI-launched Bridge's PATH does not have; an npm install lands on PATH. */
237
+ export function findKimiBinary({ home = os.homedir(), env = process.env } = {}) {
238
+ const onPath = which("kimi");
239
+ if (onPath) return onPath;
240
+ const c = path.join(kimiHome({ home, env }), "bin", process.platform === "win32" ? "kimi.exe" : "kimi");
241
+ try { fs.accessSync(c, fs.constants.X_OK); return c; } catch { return null; }
242
+ }
243
+
244
+ /**
245
+ * Kimi Code has no `mcp add`; merge-write its user-level mcp.json (docs: mcpServers.<name>
246
+ * with `url` + `headers` for HTTP). Other servers are kept; the cookbook entry keeps
247
+ * harmless tuning (timeouts, tool lists) and gets exactly one way in: this bearer.
248
+ * The file carries a token, so it is owner-only like config.json.
249
+ */
250
+ export function kimiConfigure(url, token, { home = os.homedir(), env = process.env } = {}) {
251
+ const cfgPath = path.join(kimiHome({ home, env }), "mcp.json");
252
+ let current = {};
253
+ try { current = JSON.parse(fs.readFileSync(cfgPath, "utf8")) ?? {}; } catch { /* fresh file */ }
254
+ if (typeof current !== "object" || Array.isArray(current)) current = {};
255
+ const servers = current.mcpServers && typeof current.mcpServers === "object" && !Array.isArray(current.mcpServers) ? current.mcpServers : {};
256
+ const prev = servers.cookbook && typeof servers.cookbook === "object" ? servers.cookbook : {};
257
+ const { headers: _h, bearerTokenEnvVar: _b, transport: _t, url: _u, serverUrl: _s, command: _c, args: _a, env: _e, cwd: _w, enabled: _en, ...rest } = prev;
258
+ current.mcpServers = { ...servers, cookbook: { ...rest, url, headers: { Authorization: `Bearer ${token}` } } };
259
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
260
+ fs.writeFileSync(cfgPath, JSON.stringify(current, null, 2) + "\n", { mode: 0o600 });
261
+ try { fs.chmodSync(cfgPath, 0o600); } catch { /* best-effort on platforms without chmod */ }
262
+ return cfgPath;
263
+ }
264
+
233
265
  /** agy (Antigravity) has no `mcp add`; merge-write its documented config file. */
234
266
  function agyConfigure(url, token) {
235
267
  const cfgPath = path.join(process.env.HOME || os.homedir(), ".gemini", "config", "mcp_config.json");
@@ -312,6 +344,8 @@ export function detectClis() {
312
344
  if (agy) out.push({ agent: "Gemini", vendor: "gemini", path: agy, kind: "file" });
313
345
  const codex = findCodexBinary();
314
346
  if (codex) out.push({ agent: "Codex", vendor: "codex", path: codex, kind: "codex" });
347
+ const kimi = findKimiBinary();
348
+ if (kimi) out.push({ agent: "Kimi", vendor: "kimi", path: kimi, kind: "kimi" });
315
349
  const openclaw = which("openclaw");
316
350
  if (openclaw) out.push({ agent: "OpenClaw", vendor: "openclaw", path: openclaw, kind: "openclaw" });
317
351
  return out;
@@ -355,6 +389,9 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
355
389
  } else if (cli.kind === "openclaw") {
356
390
  const wrote = openclawConfigure(mcpUrl, token);
357
391
  results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote}, streamable-http)` });
392
+ } else if (cli.kind === "kimi") {
393
+ const wrote = kimiConfigure(mcpUrl, token);
394
+ results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote})` });
358
395
  }
359
396
  } catch (e) {
360
397
  results.push({ agent: cli.agent, ok: false, detail: String(e?.message || e).slice(0, 200) });
@@ -542,14 +579,14 @@ function probeAgent(cmd) {
542
579
  /**
543
580
  * connect-agents — one command, one human approval, every installed agent CLI
544
581
  * connected to Cookbook with CORRECT ATTRIBUTION. Detects Claude, Gemini (agy),
545
- * Codex (ChatGPT app) and OpenClaw; mints one named token per agent in the same
582
+ * Codex (ChatGPT app), Kimi and OpenClaw; mints one named token per agent in the same
546
583
  * approval as the Bridge token; configures each via its official path.
547
584
  */
548
585
  export async function connectAgents(argv, { willRun = false } = {}) {
549
586
  const cfgPath = configPath(argv);
550
587
  const found = detectClis();
551
588
  if (found.length === 0) {
552
- console.log("\nNo agent CLIs found (looked for: claude, agy, codex/ChatGPT.app, openclaw).");
589
+ console.log("\nNo agent CLIs found (looked for: claude, agy, codex/ChatGPT.app, kimi, openclaw).");
553
590
  console.log(`Install one, then re-run: ${cli("connect")}\n`);
554
591
  return { ok: false, reason: "no-agents", cfgPath };
555
592
  }
package/hands.mjs CHANGED
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import fs from "node:fs";
30
30
  import os from "node:os";
31
+ import { kimiLoginState } from "./harden.mjs";
31
32
  import path from "node:path";
32
33
  import { spawn, spawnSync } from "node:child_process";
33
34
 
@@ -138,6 +139,7 @@ export const SETUP_FILES = Object.freeze([
138
139
  ".codex-bridge/config.toml", // the Bridge's own Codex home
139
140
  ".gemini/config/mcp_config.json", // agy's MCP config (agy has no `mcp add`)
140
141
  ".gemini/antigravity-cli/settings.json",
142
+ ".kimi-code/mcp.json", // kimi's MCP servers (kimi has no `mcp add`)
141
143
  ".openclaw/openclaw.json",
142
144
  ".cookbook/config.json", // Bridge config (projected: tokens stripped)
143
145
  ".cookbook/bridge.state.json", // attempt counters (projected)
@@ -173,6 +175,8 @@ const NEVER_READ = Object.freeze([
173
175
  /(^|\/)\.codex\/auth\.json$/i,
174
176
  /(^|\/)\.codex-bridge\/auth\.json$/i,
175
177
  /(^|\/)\.gemini\/oauth_creds\.json$/i,
178
+ /(^|\/)\.kimi-code\/credentials(\/|$)/i, // kimi OAuth credentials (dir + files)
179
+ /(^|\/)\.kimi-code\/config\.toml$/i, // kimi keeps provider API keys HERE, not in env
176
180
  /(^|\/)\.openclaw\/(auth|credentials)[^/]*$/i,
177
181
  /(^|\/)\.ssh(\/|$)/i,
178
182
  /(^|\/)\.gnupg(\/|$)/i,
@@ -812,6 +816,7 @@ const VERBS = {
812
816
  claude: marker(".claude/.credentials.json"),
813
817
  codex: marker(".codex/auth.json"),
814
818
  gemini: marker(".gemini/oauth_creds.json"),
819
+ kimi: kimiLoginState({ home }).loggedIn,
815
820
  openclaw: marker(".openclaw"),
816
821
  },
817
822
  setup_files_present: SETUP_FILES.filter((rel) => marker(rel)),
package/harden.mjs CHANGED
@@ -19,6 +19,12 @@
19
19
  * 3. AGY VERSION GATE — Antigravity CLI (gemini's successor) below 1.1.1 cannot call
20
20
  * MCP tools in headless -p mode: the run LOOKS fine but complete_task never lands,
21
21
  * burning every attempt. Confirmed-old versions are refused with a clear message.
22
+ *
23
+ * 4. KIMI CODE CONTRACT (0.1.12): the fourth vendor's CLI shape lives at the bottom
24
+ * of this file: stream parsing, the per-run tool jail (kimi has no --allowedTools),
25
+ * login and MCP state. Kept here rather than in a new module so the shipped file
26
+ * lists (BRIDGE_RUNTIME_FILES, next.config tracing, package.json files) stay as
27
+ * they are.
22
28
  */
23
29
  import fs from "node:fs";
24
30
  import os from "node:os";
@@ -26,7 +32,15 @@ import path from "node:path";
26
32
  import { spawn } from "node:child_process";
27
33
 
28
34
  /** Env vars that flip the official CLIs from subscription auth to API-key billing. */
29
- export const API_BILLING_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"];
35
+ export const API_BILLING_KEYS = [
36
+ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
37
+ // Kimi Code reads provider keys from config.toml, not the shell, EXCEPT the
38
+ // env-defined model (KIMI_MODEL_NAME + KIMI_MODEL_API_KEY), which would put a
39
+ // Bridge run on an API key. KIMI_API_KEY / MOONSHOT_API_KEY are the names the
40
+ // docs and the Moonshot SDKs use; hidden too, so a future CLI that reads them
41
+ // cannot flip billing either.
42
+ "KIMI_API_KEY", "MOONSHOT_API_KEY", "KIMI_MODEL_API_KEY",
43
+ ];
30
44
 
31
45
  /**
32
46
  * The environment agent processes get. By default: the parent env MINUS vendor billing
@@ -225,3 +239,228 @@ export function withCookbookMcp(command, { token, cookbookUrl } = {}) {
225
239
  });
226
240
  return { command: [command[0], "--strict-mcp-config", "--mcp-config", cfg, ...command.slice(1)], injected: true, reason: null };
227
241
  }
242
+
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+ // KIMI CODE (the fourth vendor, 0.1.12): the CLI contract, pinned empirically
245
+ // ─────────────────────────────────────────────────────────────────────────────
246
+ //
247
+ // Read off `kimi --help` (0.39.1), the prompt-mode emitter in the binary, and the
248
+ // docs at moonshotai.github.io/kimi-code (2026-09-02):
249
+ //
250
+ // - Headless: `kimi -p "<prompt>" --output-format stream-json`. `-p` takes the
251
+ // prompt as an argument (there is no stdin mode). `-p` REFUSES --yolo, --auto
252
+ // and --plan: prompt mode always runs in "auto" permission (every tool call is
253
+ // approved; static deny rules still apply). There is no --allowedTools flag.
254
+ // The jail is an agent file: `--agent-file <md>` whose frontmatter `tools:` is
255
+ // an allowlist (`mcp__cookbook__*` globs work). kimiCommand() below writes one
256
+ // per run from the command's `--allowedTools` value, so config.json keeps the
257
+ // same shape as the Claude entry and localizeCommand keeps working.
258
+ // - stream-json: one JSON object per stdout line, keyed by `role`:
259
+ // {"role":"meta","type":"system.version","version":"0.39.1"}
260
+ // {"role":"assistant","content":"..."} a finished turn's text
261
+ // {"role":"assistant","tool_calls":[{"type":"function","id":"...",
262
+ // "function":{"name":"Read","arguments":"{\"path\":\"...\"}"}}]}
263
+ // {"role":"tool","tool_call_id":"...","content":"..."} the tool's output
264
+ // {"role":"meta","type":"turn.step.retrying","failed_attempt":1,"next_attempt":2,
265
+ // "max_attempts":10,"delay_ms":557.5,"error_name":"APIConnectionError",
266
+ // "error_message":"Connection error."}
267
+ // {"role":"meta","type":"session.resume_hint","session_id":"session_<uuid>",
268
+ // "command":"kimi -r session_<uuid>","content":"To resume this session: ..."}
269
+ // Thinking is never written to stdout; tool progress and notices go to stderr.
270
+ // No usage line exists, so the receipt is duration-only.
271
+ // - Resume: `-S <id>` (hidden alias `-r`), legal with `-p`, illegal with
272
+ // `--agent-file` (a resumed session keeps the agent it was created with).
273
+ // - Model: `-m <alias>`; aliases come from ~/.kimi-code/config.toml.
274
+ // - MCP: ~/.kimi-code/mcp.json ($KIMI_CODE_HOME/mcp.json): mcpServers.<name> with
275
+ // `url` (+ `headers`) for HTTP. A project-level .kimi-code/mcp.json exists but
276
+ // sits behind the workspace-trust prompt, so the Bridge writes the user file.
277
+ // Tools are named mcp__<server>__<tool>.
278
+ // - Login: [providers.<name>] in config.toml (api_key, or an oauth table written
279
+ // by `kimi login`); OAuth credentials under ~/.kimi-code/credentials/.
280
+
281
+ /** Kimi's data root: $KIMI_CODE_HOME, else ~/.kimi-code. */
282
+ export function kimiHome({ home = process.env.HOME || os.homedir(), env = process.env } = {}) {
283
+ return env.KIMI_CODE_HOME || path.join(home, ".kimi-code");
284
+ }
285
+
286
+ /** True when this command (argv array or bare string) runs the kimi CLI. */
287
+ export function isKimiCommand(command) {
288
+ const first = Array.isArray(command) ? command[0] : command;
289
+ const base = String(first ?? "").split(/[\\/]/).pop().toLowerCase();
290
+ return base === "kimi" || base === "kimi.exe";
291
+ }
292
+
293
+ /** Probe `kimi --version` (e.g. "0.39.1"). No gate: nothing is refused, doctor prints it. */
294
+ export async function checkKimiVersion(argv, timeoutMs = 10_000) {
295
+ return { version: await probeVersion(argv, timeoutMs) };
296
+ }
297
+
298
+ /** Kimi built-in tool names the live work log should read as verbs (live.mjs
299
+ * already knows Read/Write/Edit/Bash/Grep/Glob/WebSearch by their lowercase). */
300
+ const KIMI_TOOL_VERBS = { fetchurl: "fetch", readmediafile: "read", todolist: "todo", agent: "agent", agentswarm: "agent", skill: "skill" };
301
+
302
+ /**
303
+ * One stream-json line → what the Bridge shows. Null for non-kimi lines (a claude
304
+ * line has `type`, never a top-level `role`). Pure.
305
+ * { role, text, calls: [{kind:'call', id, name, input} | {kind:'result', id, err}],
306
+ * sessionId, retry: {attempt, max, error} | null }
307
+ */
308
+ export function kimiFromLine(line) {
309
+ let j;
310
+ try { j = JSON.parse(line); } catch { return null; }
311
+ if (!j || typeof j !== "object" || typeof j.role !== "string") return null;
312
+ const ev = { role: j.role, text: null, calls: [], sessionId: null, retry: null };
313
+ if (j.role === "assistant") {
314
+ if (typeof j.content === "string" && j.content.trim()) ev.text = j.content;
315
+ for (const tc of Array.isArray(j.tool_calls) ? j.tool_calls : []) {
316
+ const raw = String(tc?.function?.name ?? tc?.name ?? "").trim();
317
+ if (!raw) continue;
318
+ let input = tc?.function?.arguments ?? tc?.arguments;
319
+ if (typeof input === "string") { try { input = JSON.parse(input); } catch { /* partial or plain text: keep the string */ } }
320
+ const name = raw.startsWith("mcp__") ? raw : (KIMI_TOOL_VERBS[raw.toLowerCase()] ?? raw);
321
+ ev.calls.push({ kind: "call", id: String(tc?.id ?? ""), name, input });
322
+ }
323
+ } else if (j.role === "tool") {
324
+ ev.calls.push({ kind: "result", id: String(j.tool_call_id ?? ""), err: kimiToolFailed(j.content) });
325
+ } else if (j.role === "meta") {
326
+ if (j.type === "session.resume_hint" && typeof j.session_id === "string" && j.session_id) ev.sessionId = j.session_id;
327
+ if (j.type === "turn.step.retrying") {
328
+ ev.retry = {
329
+ attempt: Number(j.failed_attempt) || 0,
330
+ max: Number(j.max_attempts) || 0,
331
+ error: `${j.error_name ?? "error"}${j.error_message ? `: ${j.error_message}` : ""}`,
332
+ };
333
+ }
334
+ }
335
+ return ev;
336
+ }
337
+
338
+ /** Best-effort: a tool result that reads as a failure (kimi has no is_error flag). */
339
+ function kimiToolFailed(content) {
340
+ const text = typeof content === "string" ? content : (content == null ? "" : JSON.stringify(content));
341
+ return /^\s*(error\b|tool error|permission denied|denied\b|command failed|failed to)/i.test(text);
342
+ }
343
+
344
+ /**
345
+ * The claude-shaped result envelope for a finished kimi run, so displayText,
346
+ * resultError, failureHint and extractUsage need no kimi branch. Pure.
347
+ */
348
+ export function kimiResultEnvelope({ text = "", sessionId = null, durationMs = 0, code = 0, err = "", numTurns = 0 } = {}) {
349
+ const failed = typeof code === "number" && code !== 0;
350
+ const tail = String(err ?? "").trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
351
+ return JSON.stringify({
352
+ type: "result",
353
+ subtype: failed ? "error_during_execution" : "success",
354
+ is_error: failed,
355
+ result: String(text ?? ""),
356
+ duration_ms: Math.max(0, Math.round(Number(durationMs) || 0)),
357
+ num_turns: numTurns,
358
+ ...(sessionId ? { session_id: sessionId } : {}),
359
+ ...(failed ? { errors: [tail || `kimi exited ${code}`] } : {}),
360
+ runner: "kimi",
361
+ });
362
+ }
363
+
364
+ /** Claude tool names that appear in Bridge configs → the kimi name of the same tool. */
365
+ export const KIMI_TOOL_ALIASES = Object.freeze({ WebFetch: "FetchURL", TodoWrite: "TodoList", Task: "Agent" });
366
+
367
+ /** "Bash,Read,mcp__cookbook__*" → ["Bash", "Read", "mcp__cookbook__*"], kimi names, deduped. */
368
+ export function kimiTools(spec) {
369
+ const out = [];
370
+ for (const raw of String(spec ?? "").split(",")) {
371
+ const t = raw.trim();
372
+ // Tool names are plain tokens; anything else is not a tool and never reaches YAML.
373
+ if (!t || !/^[A-Za-z0-9_*.:-]+$/.test(t)) continue;
374
+ const name = KIMI_TOOL_ALIASES[t] ?? t;
375
+ if (!out.includes(name)) out.push(name);
376
+ }
377
+ return out;
378
+ }
379
+
380
+ /** The agent file that jails one run: same system prompt, only these tools.
381
+ * An empty list means NO tools (kimi: `tools: []`), which is what a summary wants. */
382
+ export function kimiAgentFile(spec) {
383
+ const tools = kimiTools(spec);
384
+ const list = tools.length ? "\n" + tools.map((t) => ` - ${t}`).join("\n") : " []";
385
+ return [
386
+ "---",
387
+ "name: cookbook-bridge",
388
+ "description: A Cookbook Bridge run. Only the tools this task was given.",
389
+ `tools:${list}`,
390
+ "---",
391
+ "${base_prompt}",
392
+ "",
393
+ ].join("\n");
394
+ }
395
+
396
+ /**
397
+ * Turn a Bridge-shaped kimi command into what the CLI accepts: `--allowedTools X`
398
+ * (which kimi does not have) becomes `--agent-file <0600 temp file>` carrying X as
399
+ * the tools allowlist. On a resume (-S/-r/-c) the flag is simply dropped: kimi
400
+ * refuses --agent-file there and the session already carries the jail it was
401
+ * created with. Identity for non-kimi commands and commands without the flag.
402
+ * Returns { command, file, cleanup, tools }. IO; tested.
403
+ */
404
+ export function kimiCommand(command, { dir = os.tmpdir() } = {}) {
405
+ const noop = { command, file: null, cleanup: () => {}, tools: null };
406
+ if (!Array.isArray(command) || !isKimiCommand(command)) return noop;
407
+ const i = command.indexOf("--allowedTools");
408
+ if (i < 0) return noop;
409
+ const spec = i + 1 < command.length ? String(command[i + 1]) : "";
410
+ const rest = command.filter((_, k) => k !== i && k !== i + 1);
411
+ const resuming = rest.some((a) => ["-S", "--session", "-r", "--resume", "-c", "-C", "--continue"].includes(String(a)));
412
+ if (resuming || rest.includes("--agent-file") || rest.includes("--agent")) return { ...noop, command: rest };
413
+ const privDir = fs.mkdtempSync(path.join(dir, "cookbook-bridge-kimi-"));
414
+ try { fs.chmodSync(privDir, 0o700); } catch { /* best effort on platforms without modes */ }
415
+ const file = path.join(privDir, "agent.md");
416
+ fs.writeFileSync(file, kimiAgentFile(spec), { mode: 0o600 });
417
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
418
+ liveMcpDirs.add(privDir);
419
+ installMcpExitHook();
420
+ const cleanup = () => {
421
+ liveMcpDirs.delete(privDir);
422
+ try { fs.rmSync(privDir, { recursive: true, force: true }); } catch { /* best effort */ }
423
+ };
424
+ return { command: [rest[0], "--agent-file", file, ...rest.slice(1)], file, cleanup, tools: kimiTools(spec) };
425
+ }
426
+
427
+ /**
428
+ * Is kimi logged in? Reads what `kimi login` / `/login` write: a [providers.<name>]
429
+ * section in config.toml with an api_key (or a [providers.<name>.env] key, or an
430
+ * oauth sub-table), or an OAuth credential file under credentials/. Never throws.
431
+ */
432
+ export function kimiLoginState({ home = process.env.HOME || os.homedir(), env = process.env } = {}) {
433
+ const root = kimiHome({ home, env });
434
+ const configPath = path.join(root, "config.toml");
435
+ let toml = "";
436
+ try { toml = fs.readFileSync(configPath, "utf8"); } catch { /* not installed or never run */ }
437
+ const providers = new Set();
438
+ let section = null; // "providers.<name>" or "providers.<name>.env" / ".oauth"
439
+ for (const raw of toml.split(/\r?\n/)) {
440
+ const line = raw.trim();
441
+ const head = line.match(/^\[+\s*([^\]]+?)\s*\]+$/);
442
+ if (head) {
443
+ const name = head[1].replace(/"/g, "");
444
+ section = name.startsWith("providers.") ? name : null;
445
+ if (section && /^providers\.[^.]+\.oauth$/.test(section)) providers.add(section.split(".")[1]);
446
+ continue;
447
+ }
448
+ if (!section) continue;
449
+ if (/^(api_key|KIMI_API_KEY|MOONSHOT_API_KEY|ANTHROPIC_API_KEY|OPENAI_API_KEY|GOOGLE_API_KEY)\s*=\s*"[^"]+"/.test(line)) providers.add(section.split(".")[1]);
450
+ }
451
+ let credentials = 0;
452
+ try { credentials = fs.readdirSync(path.join(root, "credentials")).filter((f) => f.endsWith(".json")).length; } catch { /* none */ }
453
+ return { root, configPath, loggedIn: providers.size > 0 || credentials > 0, providers: [...providers], credentials };
454
+ }
455
+
456
+ /** The Cookbook entry in kimi's user-level mcp.json, judged against this Bridge's URL. */
457
+ export function kimiMcpState({ home = process.env.HOME || os.homedir(), env = process.env, cookbookUrl = null } = {}) {
458
+ const file = path.join(kimiHome({ home, env }), "mcp.json");
459
+ let json = null;
460
+ try { json = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* absent or unparseable */ }
461
+ const srv = json && typeof json === "object" && json.mcpServers && typeof json.mcpServers === "object" ? json.mcpServers.cookbook ?? null : null;
462
+ const url = srv && typeof srv === "object" ? (srv.url ?? srv.serverUrl ?? null) : null;
463
+ const want = cookbookUrl ? `${String(cookbookUrl).replace(/\/$/, "")}/api/mcp` : null;
464
+ const hasAuth = !!(srv && typeof srv === "object" && ((srv.headers && srv.headers.Authorization) || srv.bearerTokenEnvVar));
465
+ return { file, exists: json !== null, server: srv, url, matches: !!url && (!want || url === want), hasAuth };
466
+ }
package/local.mjs CHANGED
@@ -52,7 +52,7 @@ export function modeForTools(allowedTools) {
52
52
  * vendors' own state). Relative to home. */
53
53
  const DENIED_UNDER_HOME = [
54
54
  "Library", ".ssh", ".gnupg", ".aws", ".config", ".claude", ".codex", ".codex-bridge",
55
- ".gemini", ".openclaw", ".cursor", ".npm", ".nvm", ".Trash",
55
+ ".gemini", ".kimi-code", ".openclaw", ".cursor", ".npm", ".nvm", ".Trash",
56
56
  ];
57
57
 
58
58
  /**
@@ -100,6 +100,7 @@ export function vendorOf(agent) {
100
100
  if (base === "claude") return "claude";
101
101
  if (base === "codex" || /ChatGPT\.app|Codex\.app/.test(cmd) || agent.runner === "app-server") return "codex";
102
102
  if (base === "agy" || base === "gemini") return "gemini";
103
+ if (base === "kimi") return "kimi";
103
104
  if (base === "openclaw") return "openclaw";
104
105
  return "other";
105
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cookbook-bridge",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Run your own Claude, Codex and Gemini subscriptions against your Cookbook workspaces. One approval connects every agent CLI on your machine, with a receipt for every run.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "node": ">=18"
41
41
  },
42
42
  "scripts": {
43
- "test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs"
43
+ "test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs test/kimi.test.mjs"
44
44
  },
45
45
  "keywords": [
46
46
  "cookbook",