From fae6f11f9069eceed75d003e6ebcd45038fc13ef Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Fri, 31 Jul 2026 11:21:17 -0400
Subject: [PATCH 1/9] feat(agents): add Kimi Code CLI support
Signed-off-by: Liang Hu
---
README.md | 6 +++---
docker/Dockerfile | 2 +-
electron/ipc/agents.test.ts | 4 ++++
electron/ipc/agents.ts | 9 +++++++++
electron/ipc/pty.test.ts | 1 +
electron/ipc/pty.ts | 1 +
electron/mcp/agent-args.test.ts | 11 ++++++++++
electron/mcp/agent-args.ts | 9 +++++++++
src/lib/agent-args.test.ts | 36 +++++++++++++++++++++++++++++++++
src/lib/agent-args.ts | 14 +++++++++++--
src/store/tasks.test.ts | 20 ++++++++++++++++++
src/store/tasks.ts | 8 +++++++-
12 files changed, 114 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index be457922..b2d2a0c9 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
- Works with Claude Code, Codex, and Gemini · Every change isolated in its own git worktree · Free, open source, no extra platform fee
+ Works with Claude Code, Codex, Gemini, and Kimi Code · Every change isolated in its own git worktree · Free, open source, no extra platform fee
@@ -45,7 +45,7 @@
## Why Parallel Code?
-- **Use the AI coding tools you already trust** — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), and [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) — all from one interface.
+- **Use the AI coding tools you already trust** — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), and [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) — all from one interface.
- **Free and open source** — no extra subscription required. MIT licensed.
- **Keep every change isolated and reviewable** — each task gets its own git branch and worktree automatically.
- **Run agents in parallel, not in sequence** — five agents on five features at the same time, zero conflicts.
@@ -115,7 +115,7 @@ When you're happy with the result, merge the branch back to main from the sideba
- **macOS** — `.dmg` (universal)
- **Linux** — `.AppImage` or `.deb`
-2. **Install at least one AI coding CLI:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Antigravity CLI](https://antigravity.google/), or [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli)
+2. **Install at least one AI coding CLI:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), [Antigravity CLI](https://antigravity.google/), or [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli)
3. **Open Parallel Code**, point it at a git repo, and start dispatching tasks.
diff --git a/docker/Dockerfile b/docker/Dockerfile
index f775de19..19ce8f45 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -50,7 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd 2>/dev/null || true
# AI agent CLIs — must be present so Docker-mode tasks can execute them
-RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai
+RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai @moonshot-ai/kimi-code
# Antigravity CLI (agy) — distributed as a Go binary via the official installer
# (not on npm). The installer's `--dir` flag drops the binary straight into a
diff --git a/electron/ipc/agents.test.ts b/electron/ipc/agents.test.ts
index 566b276d..472d6520 100644
--- a/electron/ipc/agents.test.ts
+++ b/electron/ipc/agents.test.ts
@@ -8,4 +8,8 @@ describe('getSkipPermissionsArgs', () => {
expect(getSkipPermissionsArgs('claude')).toEqual(['--dangerously-skip-permissions']);
});
+
+ it('returns Kimi Code skip-permission args', () => {
+ expect(getSkipPermissionsArgs('kimi')).toEqual(['--yolo']);
+ });
});
diff --git a/electron/ipc/agents.ts b/electron/ipc/agents.ts
index 36febbba..fb021aaf 100644
--- a/electron/ipc/agents.ts
+++ b/electron/ipc/agents.ts
@@ -44,6 +44,15 @@ const DEFAULT_AGENTS: AgentDef[] = [
skip_permissions_args: ['--yolo'],
description: "Google's Gemini CLI agent",
},
+ {
+ id: 'kimi',
+ name: 'Kimi Code CLI',
+ command: 'kimi',
+ args: [],
+ resume_args: ['--continue'],
+ skip_permissions_args: ['--yolo'],
+ description: "Moonshot AI's Kimi Code CLI agent",
+ },
{
id: 'opencode',
name: 'OpenCode',
diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts
index 0a0b1e07..16cf7424 100644
--- a/electron/ipc/pty.test.ts
+++ b/electron/ipc/pty.test.ts
@@ -380,6 +380,7 @@ describe('spawnAgent docker mode', () => {
['opencode', '.config/opencode'],
['copilot', '.config/github-copilot'],
['agy', '.gemini/antigravity-cli'],
+ ['kimi', '.kimi-code'],
])(
'%s bind-mounts a user-owned host directory when shareDockerAgentAuth is enabled',
(command, relDir) => {
diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts
index b76fd8bc..925d45ad 100644
--- a/electron/ipc/pty.ts
+++ b/electron/ipc/pty.ts
@@ -714,6 +714,7 @@ const AGENT_CONFIG_DIRS: Record = {
opencode: ['.config/opencode'],
copilot: ['.config/github-copilot'],
agy: ['.gemini/antigravity-cli'],
+ kimi: ['.kimi-code'],
};
// Config files (not directories) each agent CLI uses for auth, relative to HOME.
diff --git a/electron/mcp/agent-args.test.ts b/electron/mcp/agent-args.test.ts
index c7fa90b6..bab22ddd 100644
--- a/electron/mcp/agent-args.test.ts
+++ b/electron/mcp/agent-args.test.ts
@@ -6,6 +6,7 @@ import {
isAntigravityCommand,
isCodexCommand,
isCopilotCommand,
+ isKimiCommand,
} from './agent-args.js';
const config = {
@@ -67,6 +68,16 @@ describe('MCP agent launch args', () => {
expect(buildMcpLaunchArgs('agy', '/tmp/config.json', config)).toEqual([]);
});
+ it('detects Kimi commands by executable name', () => {
+ expect(isKimiCommand('kimi')).toBe(true);
+ expect(isKimiCommand('/home/agent/.local/bin/kimi')).toBe(true);
+ expect(isKimiCommand('claude')).toBe(false);
+ });
+
+ it('emits no --mcp-config for Kimi Code', () => {
+ expect(buildMcpLaunchArgs('kimi', '/tmp/config.json', config)).toEqual([]);
+ });
+
it('detects copilot commands by executable name', () => {
expect(isCopilotCommand('copilot')).toBe(true);
expect(isCopilotCommand('/opt/homebrew/bin/copilot')).toBe(true);
diff --git a/electron/mcp/agent-args.ts b/electron/mcp/agent-args.ts
index 5add7eab..437a3fc5 100644
--- a/electron/mcp/agent-args.ts
+++ b/electron/mcp/agent-args.ts
@@ -18,6 +18,10 @@ export function isAntigravityCommand(command: string): boolean {
return command.split('/').pop() === 'agy';
}
+export function isKimiCommand(command: string): boolean {
+ return command.split('/').pop() === 'kimi';
+}
+
export function isCopilotCommand(command: string): boolean {
return command.split('/').pop() === 'copilot';
}
@@ -53,6 +57,11 @@ export function buildMcpLaunchArgs(
if (isAntigravityCommand(command)) {
return [];
}
+ // Kimi Code auto-discovers user and project MCP config files and does not
+ // accept the generic `--mcp-config` flag.
+ if (isKimiCommand(command)) {
+ return [];
+ }
// Copilot has no `--mcp-config` flag — passing it makes Copilot exit immediately
// with "unknown option" before the prompt is ever sent (#146). It accepts
// `--additional-mcp-config <@file|json>` (and also auto-discovers a workspace
diff --git a/src/lib/agent-args.test.ts b/src/lib/agent-args.test.ts
index 652f516b..5d4b67c2 100644
--- a/src/lib/agent-args.test.ts
+++ b/src/lib/agent-args.test.ts
@@ -32,6 +32,16 @@ const antigravityAgent = {
skip_permissions_args: ['--dangerously-skip-permissions'],
};
+const kimiAgent = {
+ id: 'kimi',
+ name: 'Kimi Code CLI',
+ description: 'Kimi Code agent',
+ command: 'kimi',
+ args: [],
+ resume_args: ['--continue'],
+ skip_permissions_args: ['--yolo'],
+};
+
const copilotAgent = {
id: 'copilot',
name: 'Copilot CLI',
@@ -146,6 +156,32 @@ describe('buildTaskAgentArgs', () => {
).toEqual(['-c']);
});
+ it('does not fall back to --mcp-config for Kimi Code', () => {
+ expect(
+ buildTaskAgentArgs(
+ kimiAgent,
+ {
+ skipPermissions: false,
+ mcpConfigPath: '/tmp/mcp.json',
+ },
+ false,
+ ),
+ ).toEqual([]);
+ });
+
+ it('passes Kimi Code resume and skip-permission flags without --mcp-config', () => {
+ expect(
+ buildTaskAgentArgs(
+ kimiAgent,
+ {
+ skipPermissions: true,
+ mcpConfigPath: '/tmp/mcp.json',
+ },
+ true,
+ ),
+ ).toEqual(['--continue', '--yolo']);
+ });
+
it('uses Copilot --additional-mcp-config fallback instead of the unsupported --mcp-config', () => {
expect(
buildTaskAgentArgs(
diff --git a/src/lib/agent-args.ts b/src/lib/agent-args.ts
index 9769a883..96b90623 100644
--- a/src/lib/agent-args.ts
+++ b/src/lib/agent-args.ts
@@ -9,6 +9,10 @@ function isAntigravityCommand(command: string): boolean {
return command.split('/').pop() === 'agy';
}
+function isKimiCommand(command: string): boolean {
+ return command.split('/').pop() === 'kimi';
+}
+
function isCopilotCommand(command: string): boolean {
return command.split('/').pop() === 'copilot';
}
@@ -26,8 +30,14 @@ export function isResumeArgsFailure(command: string, lastOutput: string[]): bool
}
function legacyMcpConfigArgs(command: string, mcpConfigPath: string | undefined): string[] {
- // Codex and Antigravity have no `--mcp-config` flag; passing it would break launch.
- if (!mcpConfigPath || isCodexCommand(command) || isAntigravityCommand(command)) return [];
+ // Codex, Antigravity, and Kimi have no `--mcp-config` flag; passing it would break launch.
+ if (
+ !mcpConfigPath ||
+ isCodexCommand(command) ||
+ isAntigravityCommand(command) ||
+ isKimiCommand(command)
+ )
+ return [];
// Copilot has no `--mcp-config` flag either — it exits with "unknown option" (#146).
// Use its `--additional-mcp-config <@file>` flag, which takes the same config shape.
if (isCopilotCommand(command)) return ['--additional-mcp-config', `@${mcpConfigPath}`];
diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts
index 3763bf52..7c0efb87 100644
--- a/src/store/tasks.test.ts
+++ b/src/store/tasks.test.ts
@@ -727,6 +727,26 @@ describe('MCP startup status transitions', () => {
expect(mockTasks['coord-1'].mcpStartupError).toBeUndefined();
});
+ it('allows Kimi coordinator MCP startup with persisted config path and no launch args', async () => {
+ mockTasks['coord-1'] = {
+ agentIds: ['agent-coord'],
+ shellAgentIds: [],
+ coordinatorMode: true,
+ projectId: 'proj-1',
+ gitIsolation: 'worktree',
+ worktreePath: '/repo/.worktrees/coord',
+ mcpConfigPath: '/tmp/coord.json',
+ };
+ mockAgents['agent-coord'] = { def: { command: 'kimi', args: [] } };
+ mockInvoke.mockResolvedValueOnce({ mcpLaunchArgs: [] });
+
+ markTaskMcpPending('coord-1');
+ await retryTaskMcpStartup('coord-1');
+
+ expect(mockTasks['coord-1'].mcpStartupStatus).toBe('ready');
+ expect(mockTasks['coord-1'].mcpStartupError).toBeUndefined();
+ });
+
it('missing MCP launch args leaves a Codex coordinated task in error', async () => {
mockTasks['coord-1'] = {
agentIds: [],
diff --git a/src/store/tasks.ts b/src/store/tasks.ts
index a3af3446..bb4e5a10 100644
--- a/src/store/tasks.ts
+++ b/src/store/tasks.ts
@@ -1386,13 +1386,19 @@ function isAntigravityCommand(command: string | undefined): boolean {
return command?.split('/').pop() === 'agy';
}
+function isKimiCommand(command: string | undefined): boolean {
+ return command?.split('/').pop() === 'kimi';
+}
+
function taskRequiresMcpLaunchArgs(taskId: string): boolean {
const task = store.tasks[taskId];
if (!task) return true;
const agentDef = task.agentIds[0] ? store.agents[task.agentIds[0]]?.def : undefined;
return (
isCodexCommand(agentDef?.command) ||
- (Boolean(task.mcpConfigPath) && !isAntigravityCommand(agentDef?.command))
+ (Boolean(task.mcpConfigPath) &&
+ !isAntigravityCommand(agentDef?.command) &&
+ !isKimiCommand(agentDef?.command))
);
}
From e79bf5ee024f1c96663735c3db0d4f4719bfbb02 Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Sat, 1 Aug 2026 11:12:51 -0400
Subject: [PATCH 2/9] fix(mcp): wire Kimi coordinator children
Signed-off-by: Liang Hu
---
electron/mcp/coordinator-test-harness.ts | 9 ++
electron/mcp/coordinator.test.ts | 142 +++++++++++++++++++++++
electron/mcp/coordinator.ts | 116 +++++++++++++++++-
electron/mcp/types.ts | 6 +
4 files changed, 272 insertions(+), 1 deletion(-)
diff --git a/electron/mcp/coordinator-test-harness.ts b/electron/mcp/coordinator-test-harness.ts
index 31d84bad..d48a4dee 100644
--- a/electron/mcp/coordinator-test-harness.ts
+++ b/electron/mcp/coordinator-test-harness.ts
@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => {
const mockFsMkdir = vi.fn();
const mockAtomicWriteFileSync = vi.fn();
const mockAtomicWriteFile = vi.fn();
+ const mockAppendGitInfoExcludeBlock = vi.fn();
const mockNotifyRenderer = vi.fn();
const mockLogInfo = vi.fn();
const mockLogWarn = vi.fn();
@@ -67,6 +68,7 @@ const mocks = vi.hoisted(() => {
mockFsMkdir,
mockAtomicWriteFileSync,
mockAtomicWriteFile,
+ mockAppendGitInfoExcludeBlock,
mockNotifyRenderer,
mockLogInfo,
mockLogWarn,
@@ -111,6 +113,10 @@ vi.mock('./atomic.js', () => ({
atomicWriteFile: mocks.mockAtomicWriteFile,
}));
+vi.mock('../ipc/git-exclude.js', () => ({
+ appendGitInfoExcludeBlock: mocks.mockAppendGitInfoExcludeBlock,
+}));
+
vi.mock('../shared/prompt-detect.js', () => ({
stripAnsi: (s: string) =>
s.replace(
@@ -226,6 +232,7 @@ export const {
mockFsMkdir,
mockAtomicWriteFileSync,
mockAtomicWriteFile,
+ mockAppendGitInfoExcludeBlock,
mockNotifyRenderer,
mockLogInfo,
mockLogWarn,
@@ -299,6 +306,8 @@ export function resetCoordinatorMocks(): void {
mockAtomicWriteFileSync.mockReset();
mockAtomicWriteFile.mockReset();
mockAtomicWriteFile.mockResolvedValue(undefined);
+ mockAppendGitInfoExcludeBlock.mockReset();
+ mockAppendGitInfoExcludeBlock.mockReturnValue('appended');
mockNotifyRenderer.mockReset();
mockLogInfo.mockReset();
diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts
index 510eaf3c..22fdddf4 100644
--- a/electron/mcp/coordinator.test.ts
+++ b/electron/mcp/coordinator.test.ts
@@ -17,8 +17,10 @@ import {
mockFsAccess,
mockAtomicWriteFileSync,
mockAtomicWriteFile,
+ mockAppendGitInfoExcludeBlock,
mockNotifyRenderer,
mockLogInfo,
+ mockLogWarn,
mockSpawnAgent,
mockWriteToAgent,
mockSubscribeToAgent,
@@ -3127,6 +3129,146 @@ describe('Coordinator sub-task MCP config isolation', () => {
expect(configPaths[0]).not.toBe(configPaths[1]);
});
+
+ it('writes isolated Kimi child configs to each worktree for auto-discovery', async () => {
+ mockCreateBackendTask
+ .mockResolvedValueOnce({ id: 'task-a', branch_name: 'task/a', worktree_path: '/tmp/a' })
+ .mockResolvedValueOnce({ id: 'task-b', branch_name: 'task/b', worktree_path: '/tmp/b' });
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-tok',
+ 'subtask-tok',
+ '/path/server.js',
+ );
+
+ await coordinator.createTask({ name: 'task-a', prompt: 'do a', coordinatorTaskId: 'coord-1' });
+ await coordinator.createTask({ name: 'task-b', prompt: 'do b', coordinatorTaskId: 'coord-1' });
+
+ const childWrites = mockAtomicWriteFileSync.mock.calls.filter(
+ ([configPath]) => configPath === '/tmp/a/.mcp.json' || configPath === '/tmp/b/.mcp.json',
+ );
+ expect(childWrites).toHaveLength(2);
+ const childConfigs = childWrites.map(
+ ([, raw]) =>
+ JSON.parse(raw as string) as {
+ mcpServers: {
+ 'parallel-code': { args: string[]; env: Record };
+ };
+ },
+ );
+
+ expect(childConfigs[0].mcpServers['parallel-code'].args).toContain('task-a');
+ expect(childConfigs[1].mcpServers['parallel-code'].args).toContain('task-b');
+ expect(childConfigs[0].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe(
+ 'subtask-tok',
+ );
+ expect(
+ childConfigs[0].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_DONE_TOKEN'],
+ ).not.toBe(childConfigs[1].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_DONE_TOKEN']);
+ expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith(
+ '/tmp/a',
+ '.mcp.json',
+ expect.stringContaining('.mcp.json'),
+ expect.any(Function),
+ );
+ for (const [, spawnOpts] of mockSpawnAgent.mock.calls) {
+ expect(spawnOpts).toEqual(
+ expect.objectContaining({
+ command: 'kimi',
+ args: expect.not.arrayContaining(['--mcp-config']),
+ }),
+ );
+ }
+ });
+
+ it('restores a pre-existing Kimi child MCP entry when its coordinator deregisters', async () => {
+ const configPath = '/tmp/test/.mcp.json';
+ const previousParallelCode = { command: 'user-owned-server' };
+ let currentConfig = JSON.stringify({
+ mcpServers: {
+ other: { command: 'other-server' },
+ 'parallel-code': previousParallelCode,
+ },
+ setting: true,
+ });
+ mockExistsSync.mockImplementation((path) => path === configPath);
+ mockReadFileSync.mockImplementation((path) =>
+ path === configPath ? currentConfig : '# existing\n',
+ );
+ mockAtomicWriteFileSync.mockImplementation((path, raw) => {
+ if (path === configPath) currentConfig = raw as string;
+ });
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-tok',
+ 'subtask-tok',
+ '/path/server.js',
+ );
+
+ await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
+ coordinator.deregisterCoordinator('coord-1');
+
+ const restored = JSON.parse(currentConfig) as {
+ mcpServers: Record;
+ setting: boolean;
+ };
+ expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
+ expect(restored.mcpServers.other).toEqual({ command: 'other-server' });
+ expect(restored.setting).toBe(true);
+ });
+
+ it('does not overwrite a Kimi child MCP entry changed after creation', async () => {
+ const configPath = '/tmp/test/.mcp.json';
+ let configExists = false;
+ let currentConfig = '';
+ mockExistsSync.mockImplementation((path) => path === configPath && configExists);
+ mockReadFileSync.mockImplementation((path) =>
+ path === configPath ? currentConfig : '# existing\n',
+ );
+ mockAtomicWriteFileSync.mockImplementation((path, raw) => {
+ if (path === configPath) {
+ configExists = true;
+ currentConfig = raw as string;
+ }
+ });
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-tok',
+ 'subtask-tok',
+ '/path/server.js',
+ );
+ await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
+
+ const userEntry = { command: 'user-replacement' };
+ const changed = JSON.parse(currentConfig) as {
+ mcpServers: Record;
+ };
+ changed.mcpServers['parallel-code'] = userEntry;
+ currentConfig = JSON.stringify(changed);
+ mockAtomicWriteFileSync.mockClear();
+
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3002',
+ 'new-coordinator-tok',
+ 'new-subtask-tok',
+ '/path/server.js',
+ );
+
+ expect(mockAtomicWriteFileSync.mock.calls.some(([path]) => path === configPath)).toBe(false);
+ expect(JSON.parse(currentConfig).mcpServers['parallel-code']).toEqual(userEntry);
+ expect(mockLogWarn).toHaveBeenCalledWith(
+ 'coordinator.kimi_mcp',
+ expect.stringContaining('refusing overwrite'),
+ expect.objectContaining({ taskId: 'task-1', configPath }),
+ );
+ });
});
// ─── MCP config restart rewrite tests ────────────────────────────────────────
diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts
index 6a695453..d4385f49 100644
--- a/electron/mcp/coordinator.ts
+++ b/electron/mcp/coordinator.ts
@@ -4,6 +4,7 @@
import { randomUUID, randomBytes } from 'crypto';
import { execFile } from 'child_process';
+import { join } from 'path';
import { promisify } from 'util';
import { unlinkSync, readFileSync, existsSync } from 'fs';
import { unlink as fsUnlink } from 'fs/promises';
@@ -14,9 +15,10 @@ import {
writeSubTaskMcpConfig,
writeSubTaskMcpConfigSync,
} from './config.js';
-import { buildMcpLaunchArgs } from './agent-args.js';
+import { buildMcpLaunchArgs, isKimiCommand } from './agent-args.js';
import { validateBranchName } from './validation.js';
import { atomicWriteFileSync } from './atomic.js';
+import { appendGitInfoExcludeBlock } from '../ipc/git-exclude.js';
import { ReplayCache } from './replay-cache.js';
import {
detectPreambleFiles,
@@ -90,6 +92,38 @@ const PREAMBLE_ARTIFACT_PATHS = new Set([
]);
const UNRESOLVED_LANDED_COMMIT = 'unresolved';
+type McpJsonContent = Record & {
+ mcpServers?: Record;
+};
+
+function readMcpJsonContent(configPath: string): McpJsonContent {
+ if (!existsSync(configPath)) return {};
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
+ } catch {
+ throw new Error(`${configPath} contains invalid JSON`);
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error(`${configPath} must contain a JSON object`);
+ }
+
+ const content = parsed as McpJsonContent;
+ const servers = content.mcpServers;
+ if (
+ servers !== undefined &&
+ (!servers || typeof servers !== 'object' || Array.isArray(servers))
+ ) {
+ throw new Error(`${configPath} mcpServers must be a JSON object`);
+ }
+ return content;
+}
+
+function mcpEntriesMatch(left: unknown, right: unknown): boolean {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
function pasteDelayMs(text: string): number {
const lines = text.split('\n').length;
return Math.min(500, Math.max(50, lines * 15));
@@ -608,6 +642,7 @@ export class Coordinator {
doneToken: task.doneToken,
});
writeSubTaskMcpConfigSync(mcpConfigPath, mcpConfig);
+ this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig);
}
}
@@ -867,6 +902,7 @@ export class Coordinator {
if (!this.win) throw new Error('No window set on coordinator');
const agentCommand = opts.agentCommand ?? coordinatorState.spawnDefaults.command;
+ task.agentCommand = agentCommand;
const dockerContainerName =
this.coordinators.get(task.coordinatorTaskId)?.dockerContainerName ?? null;
@@ -902,6 +938,7 @@ export class Coordinator {
await writeSubTaskMcpConfig(configPath, mcpConfig);
subTaskMcpConfigPath = configPath;
task.mcpConfigPath = configPath;
+ this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig);
}
const agentArgs = opts.agentArgs ?? coordinatorState.spawnDefaults.args;
@@ -1508,6 +1545,78 @@ export class Coordinator {
this.clearAgentBuffers(task.agentId);
}
+ private writeKimiAutoDiscoveredMcpConfig(
+ task: CoordinatedTask,
+ mcpConfig: ReturnType,
+ ): void {
+ if (!task.agentCommand || !isKimiCommand(task.agentCommand)) return;
+
+ const configPath = join(task.worktreePath, '.mcp.json');
+ const writtenParallelCode = mcpConfig.mcpServers['parallel-code'];
+ const priorState = task.autoDiscoveredMcpConfig;
+ const content = readMcpJsonContent(configPath);
+ const servers = content.mcpServers ?? {};
+
+ if (
+ priorState?.path === configPath &&
+ !mcpEntriesMatch(servers['parallel-code'], priorState.writtenParallelCode)
+ ) {
+ logWarn('coordinator.kimi_mcp', 'auto-discovered MCP config changed; refusing overwrite', {
+ taskId: task.id,
+ configPath,
+ });
+ return;
+ }
+
+ const previousParallelCode =
+ priorState?.path === configPath ? priorState.previousParallelCode : servers['parallel-code'];
+ content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode };
+ atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 });
+ task.autoDiscoveredMcpConfig = {
+ path: configPath,
+ previousParallelCode,
+ writtenParallelCode,
+ };
+
+ appendGitInfoExcludeBlock(
+ task.worktreePath,
+ '.mcp.json',
+ '# Parallel Code MCP config (contains ephemeral token)\n.mcp.json\n',
+ (err) => console.warn('[MCP] Could not git-exclude child .mcp.json:', err),
+ );
+ }
+
+ private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): void {
+ const state = task.autoDiscoveredMcpConfig;
+ task.autoDiscoveredMcpConfig = undefined;
+ if (!state) return;
+
+ try {
+ if (!existsSync(state.path)) return;
+ const content = readMcpJsonContent(state.path);
+ const servers = content.mcpServers ?? {};
+ if (!mcpEntriesMatch(servers['parallel-code'], state.writtenParallelCode)) return;
+
+ if (state.previousParallelCode !== undefined) {
+ servers['parallel-code'] = state.previousParallelCode;
+ } else {
+ delete servers['parallel-code'];
+ }
+
+ const hasServers = Object.keys(servers).length > 0;
+ const hasOtherKeys = Object.keys(content).some((key) => key !== 'mcpServers');
+ if (!hasServers && !hasOtherKeys) {
+ unlinkSync(state.path);
+ return;
+ }
+ if (hasServers) content.mcpServers = servers;
+ else delete content.mcpServers;
+ atomicWriteFileSync(state.path, JSON.stringify(content, null, 2), { mode: 0o600 });
+ } catch {
+ // Best effort: malformed, concurrently removed, or inaccessible files are left untouched.
+ }
+ }
+
/** Best-effort removal of a task's per-sub-task MCP config file. */
private unlinkMcpConfigFile(path: string | undefined): void {
if (!path) return;
@@ -1519,6 +1628,7 @@ export class Coordinator {
}
private clearTaskMcpConfig(task: CoordinatedTask): void {
+ this.restoreTaskAutoDiscoveredMcpConfig(task);
this.unlinkMcpConfigFile(task.mcpConfigPath);
task.mcpConfigPath = undefined;
}
@@ -1907,6 +2017,7 @@ export class Coordinator {
const existingTask = this.tasks.get(opts.id);
if (existingTask) {
+ existingTask.agentCommand = opts.agentCommand ?? existingTask.agentCommand;
if (safeMcpConfigPath) existingTask.mcpConfigPath = safeMcpConfigPath;
const mcpLaunchArgs = this.rewriteHydratedSubtaskMcpConfig(
existingTask,
@@ -1940,6 +2051,7 @@ export class Coordinator {
landingSummary: opts.landingSummary,
landedMetadata: opts.landedMetadata,
preambleFileExistedBefore: opts.preambleFileExistedBefore,
+ agentCommand: opts.agentCommand,
};
this.tasks.set(task.id, task);
if (opts.landedMetadata) {
@@ -2012,6 +2124,8 @@ export class Coordinator {
if (mcpConfigPath) {
writeSubTaskMcpConfigSync(mcpConfigPath, mcpConfig);
}
+ task.agentCommand = agentCommand ?? task.agentCommand ?? 'claude';
+ this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig);
return buildMcpLaunchArgs(agentCommand ?? 'claude', mcpConfigPath, mcpConfig);
}
diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts
index 1b0044b4..7edb31e1 100644
--- a/electron/mcp/types.ts
+++ b/electron/mcp/types.ts
@@ -16,6 +16,12 @@ export interface CoordinatedTask {
initialPrompt?: string;
automationWriteInFlight?: boolean;
mcpConfigPath?: string; // path to per-task tmp config, deleted on cleanup
+ autoDiscoveredMcpConfig?: {
+ path: string;
+ previousParallelCode?: unknown;
+ writtenParallelCode: unknown;
+ };
+ agentCommand?: string;
doneToken?: string; // per-task token; only the owning sub-task may call /done
preambleFileExistedBefore?: boolean; // true if the preamble file existed before injection (even if empty)
signalDoneAt?: Date; // set when sub-task explicitly calls signal_done
From e6236f300c5193ed305d57fd243078c9794d35b0 Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Sun, 2 Aug 2026 10:10:26 -0400
Subject: [PATCH 3/9] fix(mcp): preserve Kimi launch args on hydration
---
electron/mcp/coordinator.test.ts | 30 ++++++++++++++++++++++++++++++
electron/mcp/coordinator.ts | 2 +-
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts
index 22fdddf4..7b0698f5 100644
--- a/electron/mcp/coordinator.test.ts
+++ b/electron/mcp/coordinator.test.ts
@@ -3551,6 +3551,36 @@ describe('Coordinator hydrateTask — restart hydration', () => {
expect(task?.status).toBe('exited');
});
+ it('hydrateTask keeps Kimi launch args empty when the existing task command is reused', async () => {
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-token',
+ 'subtask-token',
+ '/path/server.js',
+ );
+ const task = await coordinator.createTask({
+ name: 'kimi-task',
+ prompt: 'do',
+ coordinatorTaskId: 'coord-1',
+ });
+
+ const result = coordinator.hydrateTask({
+ id: task.id,
+ name: task.name,
+ projectId: task.projectId,
+ projectRoot: task.projectRoot,
+ branchName: task.branchName,
+ worktreePath: task.worktreePath,
+ agentId: task.agentId,
+ coordinatorTaskId: task.coordinatorTaskId,
+ mcpConfigPath: task.mcpConfigPath,
+ });
+
+ expect(result.mcpLaunchArgs).toEqual([]);
+ });
+
it('hydrateTask restores an undelivered initial prompt for backend delivery', () => {
coordinator.hydrateTask({
id: 'hydrated-1',
diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts
index d4385f49..7d8d5def 100644
--- a/electron/mcp/coordinator.ts
+++ b/electron/mcp/coordinator.ts
@@ -2126,7 +2126,7 @@ export class Coordinator {
}
task.agentCommand = agentCommand ?? task.agentCommand ?? 'claude';
this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig);
- return buildMcpLaunchArgs(agentCommand ?? 'claude', mcpConfigPath, mcpConfig);
+ return buildMcpLaunchArgs(task.agentCommand, mcpConfigPath, mcpConfig);
}
isRegisteredCoordinator(coordinatorTaskId: string): boolean {
From d4ae1a60d438956ed1cb3de32d1c03c384761ec0 Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Tue, 4 Aug 2026 10:02:13 -0400
Subject: [PATCH 4/9] fix(mcp): preserve Kimi config restoration state
---
electron/ipc/register.ts | 2 +
electron/mcp/coordinator.test.ts | 140 +++++++++++++++++++++++++
electron/mcp/coordinator.ts | 170 +++++++++++++++++++++++--------
electron/mcp/types.ts | 12 ++-
src/App.tsx | 8 +-
src/store/autosave.ts | 1 +
src/store/persistence.test.ts | 60 +++++++++++
src/store/persistence.ts | 3 +
src/store/tasks.test.ts | 15 +++
src/store/tasks.ts | 42 +++++++-
src/store/types.ts | 8 ++
11 files changed, 407 insertions(+), 54 deletions(-)
diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts
index 25be5e46..9e0c9809 100644
--- a/electron/ipc/register.ts
+++ b/electron/ipc/register.ts
@@ -1507,6 +1507,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
landingSummary?: string;
landedMetadata?: import('../mcp/types.js').LandedMetadata;
mcpConfigPath?: string;
+ autoDiscoveredMcpConfig?: import('../mcp/types.js').AutoDiscoveredMcpConfigState;
agentCommand?: string;
preambleFileExistedBefore?: boolean;
initialPrompt?: string;
@@ -1545,6 +1546,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
landingSummary: args.landingSummary,
landedMetadata: args.landedMetadata,
mcpConfigPath: args.mcpConfigPath,
+ autoDiscoveredMcpConfig: args.autoDiscoveredMcpConfig,
agentCommand: args.agentCommand,
preambleFileExistedBefore: args.preambleFileExistedBefore,
initialPrompt: args.initialPrompt,
diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts
index 7b0698f5..6efd45a4 100644
--- a/electron/mcp/coordinator.test.ts
+++ b/electron/mcp/coordinator.test.ts
@@ -1523,6 +1523,74 @@ describe('Coordinator land_self', () => {
);
});
+ it('restores a tracked Kimi .mcp.json before checking and merging the worktree', async () => {
+ const configPath = '/tmp/test/.mcp.json';
+ const previousParallelCode = { command: 'user-owned-server' };
+ let currentConfig = JSON.stringify({
+ mcpServers: { 'parallel-code': previousParallelCode },
+ });
+ mockExistsSync.mockImplementation((path) => path === configPath);
+ mockReadFileSync.mockImplementation((path) =>
+ path === configPath ? currentConfig : '# existing\n',
+ );
+ mockAtomicWriteFileSync.mockImplementation((path, raw) => {
+ if (path === configPath) currentConfig = raw as string;
+ });
+ mockExecFile.mockImplementation(
+ (
+ _cmd: string,
+ args: string[],
+ _opts: unknown,
+ cb: (err: Error | null, stdout: string, stderr: string) => void,
+ ) => {
+ if (args.join(' ') === 'rev-parse --abbrev-ref HEAD') {
+ cb(null, 'task/test\n', '');
+ return;
+ }
+ if (args[0] === 'status') {
+ const config = JSON.parse(currentConfig) as {
+ mcpServers: Record;
+ };
+ const restored = config.mcpServers['parallel-code'];
+ const isRestored = JSON.stringify(restored) === JSON.stringify(previousParallelCode);
+ cb(null, isRestored ? '' : ' M .mcp.json\n', '');
+ return;
+ }
+ if (args.join(' ') === 'rev-parse HEAD') {
+ cb(null, 'landed-sha\n', '');
+ return;
+ }
+ cb(null, '', '');
+ },
+ );
+
+ const kimiCoordinator = new Coordinator();
+ kimiCoordinator.setWindow(mockWin);
+ kimiCoordinator.setDefaultProject('proj-1', '/tmp/project');
+ kimiCoordinator.registerCoordinator('coord-kimi', 'proj-1', {
+ worktreePath: '/tmp/project',
+ });
+ kimiCoordinator.setCoordinatorSpawnDefaults('coord-kimi', 'kimi', []);
+ kimiCoordinator.setMCPServerInfo(
+ 'coord-kimi',
+ 'http://localhost:3001',
+ 'coordinator-token',
+ 'subtask-token',
+ '/path/server.js',
+ );
+ await kimiCoordinator.createTask({
+ name: 'test',
+ prompt: 'do',
+ coordinatorTaskId: 'coord-kimi',
+ });
+
+ await kimiCoordinator.landSelf('task-1', { verification });
+
+ const restored = JSON.parse(currentConfig) as { mcpServers: Record };
+ expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
+ expect(vi.mocked(mergeTask)).toHaveBeenCalled();
+ });
+
it('stages a landed notification so the coordinator hears about successful self-land', async () => {
await coordinator.landSelf('task-1', { verification, summary: 'done' });
@@ -3221,6 +3289,78 @@ describe('Coordinator sub-task MCP config isolation', () => {
expect(restored.setting).toBe(true);
});
+ it('preserves the original Kimi entry across restart hydration and deregistration', async () => {
+ const configPath = '/tmp/test/.mcp.json';
+ const previousParallelCode = { command: 'user-owned-server' };
+ let currentConfig = JSON.stringify({
+ mcpServers: {
+ other: { command: 'other-server' },
+ 'parallel-code': previousParallelCode,
+ },
+ });
+ mockExistsSync.mockImplementation((path) => path === configPath);
+ mockReadFileSync.mockImplementation((path) =>
+ path === configPath ? currentConfig : '# existing\n',
+ );
+ mockAtomicWriteFileSync.mockImplementation((path, raw) => {
+ if (path === configPath) currentConfig = raw as string;
+ });
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'old-coordinator-token',
+ 'old-subtask-token',
+ '/path/server.js',
+ );
+ const task = await coordinator.createTask({
+ name: 'test',
+ prompt: 'do',
+ coordinatorTaskId: 'coord-1',
+ });
+ const persistedState = task.autoDiscoveredMcpConfig;
+ expect(persistedState?.previousParallelCode).toEqual(previousParallelCode);
+
+ const restarted = new Coordinator();
+ restarted.setWindow(mockWin);
+ restarted.setDefaultProject('proj-1', '/tmp/project');
+ restarted.registerCoordinator('coord-1', 'proj-1');
+ restarted.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3002',
+ 'new-coordinator-token',
+ 'new-subtask-token',
+ '/path/server.js',
+ );
+ const result = restarted.hydrateTask({
+ id: task.id,
+ name: task.name,
+ projectId: task.projectId,
+ projectRoot: task.projectRoot,
+ branchName: task.branchName,
+ worktreePath: task.worktreePath,
+ agentId: task.agentId,
+ coordinatorTaskId: task.coordinatorTaskId,
+ mcpConfigPath: task.mcpConfigPath,
+ autoDiscoveredMcpConfig: persistedState,
+ agentCommand: 'kimi',
+ });
+
+ expect(result.autoDiscoveredMcpConfig?.previousParallelCode).toEqual(previousParallelCode);
+ const refreshed = JSON.parse(currentConfig) as {
+ mcpServers: { 'parallel-code': { env: Record } };
+ };
+ expect(refreshed.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe(
+ 'new-subtask-token',
+ );
+
+ restarted.deregisterCoordinator('coord-1');
+
+ const restored = JSON.parse(currentConfig) as { mcpServers: Record };
+ expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
+ expect(restored.mcpServers.other).toEqual({ command: 'other-server' });
+ });
+
it('does not overwrite a Kimi child MCP entry changed after creation', async () => {
const configPath = '/tmp/test/.mcp.json';
let configExists = false;
diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts
index 7d8d5def..73e1b96a 100644
--- a/electron/mcp/coordinator.ts
+++ b/electron/mcp/coordinator.ts
@@ -2,7 +2,7 @@
// Manages task lifecycle independently of the SolidJS renderer,
// using existing backend primitives (pty, git, tasks).
-import { randomUUID, randomBytes } from 'crypto';
+import { createHash, randomUUID, randomBytes } from 'crypto';
import { execFile } from 'child_process';
import { join } from 'path';
import { promisify } from 'util';
@@ -66,6 +66,7 @@ import type {
ApiTaskDetail,
ApiDiffResult,
ApiLandSelfResult,
+ AutoDiscoveredMcpConfigState,
LandSelfInput,
LandingState,
SubtaskVerification,
@@ -120,8 +121,29 @@ function readMcpJsonContent(configPath: string): McpJsonContent {
return content;
}
-function mcpEntriesMatch(left: unknown, right: unknown): boolean {
- return JSON.stringify(left) === JSON.stringify(right);
+function mcpEntryFingerprint(value: unknown): string {
+ return createHash('sha256')
+ .update(JSON.stringify(value) ?? 'undefined')
+ .digest('hex');
+}
+
+function validateAutoDiscoveredMcpConfigState(
+ value: unknown,
+ worktreePath: string,
+): AutoDiscoveredMcpConfigState | undefined {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
+ const state = value as Record;
+ if (state.path !== join(worktreePath, '.mcp.json')) return undefined;
+ if (
+ typeof state.writtenParallelCodeFingerprint !== 'string' ||
+ !/^[a-f0-9]{64}$/.test(state.writtenParallelCodeFingerprint)
+ )
+ return undefined;
+ return {
+ path: state.path,
+ previousParallelCode: state.previousParallelCode,
+ writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint,
+ };
}
function pasteDelayMs(text: string): number {
@@ -1002,6 +1024,7 @@ export class Coordinator {
agentId: task.agentId,
coordinatorTaskId: task.coordinatorTaskId,
mcpConfigPath: subTaskMcpConfigPath,
+ autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig,
prompt: task.initialPrompt,
preambleFileExistedBefore: task.preambleFileExistedBefore,
agentCommand: agentCommand,
@@ -1559,7 +1582,7 @@ export class Coordinator {
if (
priorState?.path === configPath &&
- !mcpEntriesMatch(servers['parallel-code'], priorState.writtenParallelCode)
+ mcpEntryFingerprint(servers['parallel-code']) !== priorState.writtenParallelCodeFingerprint
) {
logWarn('coordinator.kimi_mcp', 'auto-discovered MCP config changed; refusing overwrite', {
taskId: task.id,
@@ -1575,8 +1598,9 @@ export class Coordinator {
task.autoDiscoveredMcpConfig = {
path: configPath,
previousParallelCode,
- writtenParallelCode,
+ writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode),
};
+ this.syncAutoDiscoveredMcpConfig(task);
appendGitInfoExcludeBlock(
task.worktreePath,
@@ -1586,16 +1610,25 @@ export class Coordinator {
);
}
- private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): void {
+ private syncAutoDiscoveredMcpConfig(task: CoordinatedTask): void {
+ this.notifyRenderer(IPC.MCP_TaskStateSync, {
+ taskId: task.id,
+ autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig ?? null,
+ });
+ }
+
+ private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): boolean {
const state = task.autoDiscoveredMcpConfig;
task.autoDiscoveredMcpConfig = undefined;
- if (!state) return;
+ if (!state) return false;
+ this.syncAutoDiscoveredMcpConfig(task);
try {
- if (!existsSync(state.path)) return;
+ if (!existsSync(state.path)) return true;
const content = readMcpJsonContent(state.path);
const servers = content.mcpServers ?? {};
- if (!mcpEntriesMatch(servers['parallel-code'], state.writtenParallelCode)) return;
+ if (mcpEntryFingerprint(servers['parallel-code']) !== state.writtenParallelCodeFingerprint)
+ return false;
if (state.previousParallelCode !== undefined) {
servers['parallel-code'] = state.previousParallelCode;
@@ -1607,13 +1640,31 @@ export class Coordinator {
const hasOtherKeys = Object.keys(content).some((key) => key !== 'mcpServers');
if (!hasServers && !hasOtherKeys) {
unlinkSync(state.path);
- return;
+ return true;
}
if (hasServers) content.mcpServers = servers;
else delete content.mcpServers;
atomicWriteFileSync(state.path, JSON.stringify(content, null, 2), { mode: 0o600 });
+ return true;
} catch {
// Best effort: malformed, concurrently removed, or inaccessible files are left untouched.
+ return false;
+ }
+ }
+
+ private refreshTaskMcpConfigAfterLandingFailure(task: CoordinatedTask): void {
+ try {
+ this.rewriteHydratedSubtaskMcpConfig(
+ task,
+ task.coordinatorTaskId,
+ task.mcpConfigPath,
+ task.agentCommand,
+ );
+ } catch (err) {
+ logWarn('coordinator.kimi_mcp', 'failed to restore MCP config after landing failure', {
+ taskId: task.id,
+ error: err instanceof Error ? err.message : String(err),
+ });
}
}
@@ -1704,9 +1755,11 @@ export class Coordinator {
task.verification = input.verification;
task.landingSummary = input.summary;
+ const shouldRefreshMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
try {
await this.prepareCleanSelfLandingWorktree(task);
} catch (err) {
+ if (shouldRefreshMcpConfig) this.refreshTaskMcpConfigAfterLandingFailure(task);
const reason = err instanceof Error ? err.message : String(err);
this.escalateLanding(task, 'landing_escalated', reason);
throw err;
@@ -1716,6 +1769,7 @@ export class Coordinator {
try {
mergeResult = await this.runGitMerge(task, { squash: false });
} catch (err) {
+ if (shouldRefreshMcpConfig) this.refreshTaskMcpConfigAfterLandingFailure(task);
const reason = err instanceof Error ? err.message : String(err);
const state =
reason.toLowerCase().includes('conflict') || reason.includes('Merge failed')
@@ -1797,42 +1851,51 @@ export class Coordinator {
const task = this.tasks.get(taskId);
if (!task) throw new Error(`Task not found: ${taskId}`);
this.assertTaskCanBeMerged(task);
+ const shouldRefreshMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
- // Strip injected preamble files before staging so they don't land in history,
- // then auto-commit any uncommitted changes in the task worktree before merging.
- if (task.worktreePath) {
- await stripPreambleFromBranch(task);
- try {
- await execAsync('git', ['add', '-A'], { cwd: task.worktreePath });
- await execAsync('git', ['commit', '-m', 'WIP: auto-commit before merge'], {
- cwd: task.worktreePath,
- });
- } catch {
- // Commit failed — check if uncommitted changes still exist
- const { stdout: statusOut } = await execAsync('git', ['status', '--porcelain'], {
- cwd: task.worktreePath,
- });
- if (statusOut.trim()) {
- throw new Error(
- `Auto-commit failed and the task worktree still has uncommitted changes. ` +
- `Please commit or discard changes in ${task.worktreePath} before merging.`,
- );
+ try {
+ // Strip injected preamble files before staging so they don't land in history,
+ // then auto-commit any uncommitted changes in the task worktree before merging.
+ if (task.worktreePath) {
+ await stripPreambleFromBranch(task);
+ try {
+ await execAsync('git', ['add', '-A'], { cwd: task.worktreePath });
+ await execAsync('git', ['commit', '-m', 'WIP: auto-commit before merge'], {
+ cwd: task.worktreePath,
+ });
+ } catch {
+ // Commit failed — check if uncommitted changes still exist
+ const { stdout: statusOut } = await execAsync('git', ['status', '--porcelain'], {
+ cwd: task.worktreePath,
+ });
+ if (statusOut.trim()) {
+ throw new Error(
+ `Auto-commit failed and the task worktree still has uncommitted changes. ` +
+ `Please commit or discard changes in ${task.worktreePath} before merging.`,
+ );
+ }
+ // Nothing to commit — swallow silently
}
- // Nothing to commit — swallow silently
}
- }
- const result = await this.runGitMerge(task, opts);
+ const result = await this.runGitMerge(task, opts);
- if (opts?.cleanup) {
- await this.cleanupTask(taskId);
- }
+ if (opts?.cleanup) {
+ await this.cleanupTask(taskId);
+ }
+ if (this.tasks.has(taskId) && shouldRefreshMcpConfig) {
+ this.refreshTaskMcpConfigAfterLandingFailure(task);
+ }
- return {
- mainBranch: result.mainBranch,
- linesAdded: result.linesAdded,
- linesRemoved: result.linesRemoved,
- };
+ return {
+ mainBranch: result.mainBranch,
+ linesAdded: result.linesAdded,
+ linesRemoved: result.linesRemoved,
+ };
+ } catch (err) {
+ if (shouldRefreshMcpConfig) this.refreshTaskMcpConfigAfterLandingFailure(task);
+ throw err;
+ }
}
private assertTaskCanBeMerged(task: CoordinatedTask): void {
@@ -1995,12 +2058,16 @@ export class Coordinator {
landingSummary?: string;
landedMetadata?: CoordinatedTask['landedMetadata'];
mcpConfigPath?: string;
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState;
agentCommand?: string;
preambleFileExistedBefore?: boolean;
initialPrompt?: string;
pendingPrompts?: string[];
assignedPromptDelivered?: boolean;
- }): { mcpLaunchArgs?: string[] } {
+ }): {
+ mcpLaunchArgs?: string[];
+ autoDiscoveredMcpConfig: AutoDiscoveredMcpConfigState | null;
+ } {
const coordinatorState = this.coordinators.get(opts.coordinatorTaskId);
if (!coordinatorState) {
throw new Error(`coordinator ${opts.coordinatorTaskId} is not registered`);
@@ -2019,13 +2086,22 @@ export class Coordinator {
if (existingTask) {
existingTask.agentCommand = opts.agentCommand ?? existingTask.agentCommand;
if (safeMcpConfigPath) existingTask.mcpConfigPath = safeMcpConfigPath;
+ if (opts.autoDiscoveredMcpConfig !== undefined) {
+ existingTask.autoDiscoveredMcpConfig = validateAutoDiscoveredMcpConfigState(
+ opts.autoDiscoveredMcpConfig,
+ existingTask.worktreePath,
+ );
+ }
const mcpLaunchArgs = this.rewriteHydratedSubtaskMcpConfig(
existingTask,
opts.coordinatorTaskId,
safeMcpConfigPath ?? existingTask.mcpConfigPath,
opts.agentCommand,
);
- return { mcpLaunchArgs };
+ return {
+ mcpLaunchArgs,
+ autoDiscoveredMcpConfig: existingTask.autoDiscoveredMcpConfig ?? null,
+ };
}
const task: CoordinatedTask = {
@@ -2052,6 +2128,10 @@ export class Coordinator {
landedMetadata: opts.landedMetadata,
preambleFileExistedBefore: opts.preambleFileExistedBefore,
agentCommand: opts.agentCommand,
+ autoDiscoveredMcpConfig: validateAutoDiscoveredMcpConfigState(
+ opts.autoDiscoveredMcpConfig,
+ opts.worktreePath,
+ ),
};
this.tasks.set(task.id, task);
if (opts.landedMetadata) {
@@ -2093,12 +2173,16 @@ export class Coordinator {
} catch {
/* agent not yet spawned — onPtyEvent('spawn') will subscribe when it starts */
}
- return { mcpLaunchArgs };
+ return {
+ mcpLaunchArgs,
+ autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig ?? null,
+ };
} catch (err) {
// Clean up partial map entries so the agentId doesn't linger in state.
this.clearAgentBuffers(agentId);
this.subscribers.delete(agentId);
this.clearPromptDeliveryState(task.id);
+ this.clearTaskMcpConfig(task);
this.tasks.delete(task.id);
throw err;
}
diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts
index 7edb31e1..00f49e58 100644
--- a/electron/mcp/types.ts
+++ b/electron/mcp/types.ts
@@ -1,5 +1,11 @@
// Shared types for the MCP coordinating-agent system.
+export interface AutoDiscoveredMcpConfigState {
+ path: string;
+ previousParallelCode?: unknown;
+ writtenParallelCodeFingerprint: string;
+}
+
export interface CoordinatedTask {
id: string;
name: string;
@@ -16,11 +22,7 @@ export interface CoordinatedTask {
initialPrompt?: string;
automationWriteInFlight?: boolean;
mcpConfigPath?: string; // path to per-task tmp config, deleted on cleanup
- autoDiscoveredMcpConfig?: {
- path: string;
- previousParallelCode?: unknown;
- writtenParallelCode: unknown;
- };
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState;
agentCommand?: string;
doneToken?: string; // per-task token; only the owning sub-task may call /done
preambleFileExistedBefore?: boolean; // true if the preamble file existed before injection (even if empty)
diff --git a/src/App.tsx b/src/App.tsx
index b062b29b..8d92a492 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -65,7 +65,7 @@ import {
markTaskMcpError,
} from './store/store';
import { isGitHubUrl } from './lib/github-url';
-import type { PersistedWindowState } from './store/types';
+import type { PersistedWindowState, Task } from './store/types';
import {
initShortcuts,
registerFromRegistry,
@@ -451,7 +451,10 @@ function App() {
if (!projectRoot) continue;
markTaskMcpPending(task.id);
hydratePromises.push(
- invoke<{ mcpLaunchArgs?: string[] }>(IPC.MCP_HydrateCoordinatedTask, {
+ invoke<{
+ mcpLaunchArgs?: string[];
+ autoDiscoveredMcpConfig?: Task['autoDiscoveredMcpConfig'] | null;
+ }>(IPC.MCP_HydrateCoordinatedTask, {
id: task.id,
name: task.name,
projectId: task.projectId,
@@ -470,6 +473,7 @@ function App() {
landingSummary: task.landingSummary,
landedMetadata: task.landedMetadata,
mcpConfigPath: task.mcpConfigPath,
+ autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig,
agentCommand: store.agents[task.agentIds[0]]?.def.command ?? 'claude',
preambleFileExistedBefore: task.preambleFileExistedBefore,
initialPrompt: task.initialPrompt,
diff --git a/src/store/autosave.ts b/src/store/autosave.ts
index 9b269c89..e911800e 100644
--- a/src/store/autosave.ts
+++ b/src/store/autosave.ts
@@ -66,6 +66,7 @@ export function persistedSnapshot(): string {
coordinatedBy: t.coordinatedBy,
coordinatorMode: t.coordinatorMode,
mcpConfigPath: t.mcpConfigPath,
+ autoDiscoveredMcpConfig: t.autoDiscoveredMcpConfig,
preambleFileExistedBefore: t.preambleFileExistedBefore,
signalDoneReceived: t.signalDoneReceived,
signalDoneAt: t.signalDoneAt,
diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts
index 6ae20978..0c369ed9 100644
--- a/src/store/persistence.test.ts
+++ b/src/store/persistence.test.ts
@@ -243,6 +243,66 @@ describe('landing state persistence', () => {
});
});
+describe('Kimi auto-discovered MCP config persistence', () => {
+ const autoDiscoveredMcpConfig = {
+ path: '/repo/.worktrees/task-1/.mcp.json',
+ previousParallelCode: { command: 'user-owned-server' },
+ writtenParallelCodeFingerprint: 'a'.repeat(64),
+ };
+
+ it('saves the restoration snapshot with a coordinated task', async () => {
+ setStore('taskOrder', ['task-1']);
+ setStore('tasks', {
+ 'task-1': {
+ id: 'task-1',
+ name: 'Task',
+ projectId: 'project-1',
+ branchName: 'task/task-1',
+ worktreePath: '/repo/.worktrees/task-1',
+ agentIds: [],
+ shellAgentIds: [],
+ notes: '',
+ lastPrompt: '',
+ gitIsolation: 'worktree',
+ coordinatedBy: 'coord-1',
+ autoDiscoveredMcpConfig,
+ },
+ });
+ mockInvoke.mockResolvedValueOnce(undefined);
+
+ await saveState();
+
+ const saved = JSON.parse(mockInvoke.mock.calls[0][1].json);
+ expect(saved.tasks['task-1'].autoDiscoveredMcpConfig).toEqual(autoDiscoveredMcpConfig);
+ });
+
+ it('restores the snapshot for restart hydration', async () => {
+ const def = agentDef();
+ mockInvoke.mockResolvedValueOnce(
+ JSON.stringify({
+ projects: [{ id: 'project-1', name: 'Repo', path: '/repo', color: 'hsl(0, 70%, 75%)' }],
+ lastProjectId: 'project-1',
+ lastAgentId: null,
+ taskOrder: ['task-1'],
+ collapsedTaskOrder: [],
+ tasks: {
+ 'task-1': {
+ ...persistedTask(def),
+ coordinatedBy: 'coord-1',
+ autoDiscoveredMcpConfig,
+ },
+ },
+ activeTaskId: 'task-1',
+ sidebarVisible: true,
+ }),
+ );
+
+ await loadState();
+
+ expect(store.tasks['task-1'].autoDiscoveredMcpConfig).toEqual(autoDiscoveredMcpConfig);
+ });
+});
+
describe('PR URL persistence', () => {
it('persists task PR URLs', async () => {
setStore('taskOrder', ['task-1']);
diff --git a/src/store/persistence.ts b/src/store/persistence.ts
index 33086734..fe860cff 100644
--- a/src/store/persistence.ts
+++ b/src/store/persistence.ts
@@ -153,6 +153,7 @@ function toPersistedTask(task: Task, agentDefs: AgentDef[], collapsed?: boolean)
coordinatedBy: task.coordinatedBy,
controlledBy: task.controlledBy,
mcpConfigPath: task.mcpConfigPath,
+ autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig,
signalDoneReceived: task.signalDoneReceived,
signalDoneAt: task.signalDoneAt,
signalDoneConsumed: task.signalDoneConsumed,
@@ -684,6 +685,7 @@ export async function loadState(): Promise {
mcpStartupStatus:
pt.coordinatorMode || pt.coordinatedBy ? ('pending' as const) : undefined,
mcpConfigPath: pt.mcpConfigPath,
+ autoDiscoveredMcpConfig: pt.autoDiscoveredMcpConfig,
signalDoneReceived: pt.signalDoneReceived,
signalDoneAt: pt.signalDoneAt,
signalDoneConsumed: pt.signalDoneConsumed,
@@ -790,6 +792,7 @@ export async function loadState(): Promise {
mcpStartupStatus:
pt.coordinatorMode || pt.coordinatedBy ? ('pending' as const) : undefined,
mcpConfigPath: pt.mcpConfigPath,
+ autoDiscoveredMcpConfig: pt.autoDiscoveredMcpConfig,
signalDoneReceived: pt.signalDoneReceived,
signalDoneAt: pt.signalDoneAt,
signalDoneConsumed: pt.signalDoneConsumed,
diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts
index 7c0efb87..f646f5c2 100644
--- a/src/store/tasks.test.ts
+++ b/src/store/tasks.test.ts
@@ -1328,6 +1328,21 @@ describe('MCP_TaskStateSync listener', () => {
expect(mockTasks['task-1'].automationWriteInFlight).toBe(true);
});
+ it('stores and clears the auto-discovered MCP restoration snapshot', () => {
+ const snapshot = {
+ path: '/repo/.worktrees/task-1/.mcp.json',
+ previousParallelCode: { command: 'user-owned-server' },
+ writtenParallelCodeFingerprint: 'a'.repeat(64),
+ };
+
+ taskStateSyncHandler({ taskId: 'task-1', autoDiscoveredMcpConfig: snapshot });
+ expect(mockTasks['task-1'].autoDiscoveredMcpConfig).toEqual(snapshot);
+ expect(mockSaveState).toHaveBeenCalled();
+
+ taskStateSyncHandler({ taskId: 'task-1', autoDiscoveredMcpConfig: null });
+ expect(mockTasks['task-1'].autoDiscoveredMcpConfig).toBeUndefined();
+ });
+
it('stores landed pending-review and verification sync fields', () => {
taskStateSyncHandler({
taskId: 'task-1',
diff --git a/src/store/tasks.ts b/src/store/tasks.ts
index bb4e5a10..67b6ee64 100644
--- a/src/store/tasks.ts
+++ b/src/store/tasks.ts
@@ -27,7 +27,13 @@ import type {
StepEntry,
} from '../ipc/types';
import { parseGitHubUrl, taskNameFromGitHubUrl } from '../lib/github-url';
-import type { Agent, Task, GitIsolationMode, AppStore } from './types';
+import type {
+ Agent,
+ AppStore,
+ AutoDiscoveredMcpConfigState,
+ GitIsolationMode,
+ Task,
+} from './types';
import type { DockerSource } from '../lib/docker';
import { COORDINATOR_PREAMBLE } from './coordinator-preamble';
import {
@@ -1092,6 +1098,7 @@ interface MCPTaskCreatedEvent {
coordinatorTaskId: string;
prompt?: string;
mcpConfigPath?: string;
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState;
preambleFileExistedBefore?: boolean;
agentCommand?: string;
agentArgs?: string[];
@@ -1126,6 +1133,7 @@ export function initMCPListeners(): () => void {
// background sub-task panels may never mount a PromptInput.
initialPrompt: evt.prompt,
mcpConfigPath: evt.mcpConfigPath,
+ autoDiscoveredMcpConfig: evt.autoDiscoveredMcpConfig,
mcpLaunchArgs: evt.mcpLaunchArgs,
preambleFileExistedBefore: evt.preambleFileExistedBefore,
skipPermissions: evt.skipPermissions ?? false,
@@ -1299,6 +1307,7 @@ export function initMCPListeners(): () => void {
controlledBy?: 'coordinator' | 'human' | null;
automationWriteInFlight?: boolean;
mcpConfigPath?: string | null;
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState | null;
mcpStartupStatus?: 'pending' | 'ready' | 'error' | null;
mcpStartupError?: string | null;
};
@@ -1337,11 +1346,18 @@ export function initMCPListeners(): () => void {
setStore('tasks', evt.taskId, 'automationWriteInFlight', evt.automationWriteInFlight);
if (evt.mcpConfigPath !== undefined)
setStore('tasks', evt.taskId, 'mcpConfigPath', evt.mcpConfigPath ?? undefined);
+ if (evt.autoDiscoveredMcpConfig !== undefined)
+ setStore(
+ 'tasks',
+ evt.taskId,
+ 'autoDiscoveredMcpConfig',
+ evt.autoDiscoveredMcpConfig ?? undefined,
+ );
if (evt.mcpStartupStatus !== undefined)
setStore('tasks', evt.taskId, 'mcpStartupStatus', evt.mcpStartupStatus ?? undefined);
if (evt.mcpStartupError !== undefined)
setStore('tasks', evt.taskId, 'mcpStartupError', evt.mcpStartupError ?? undefined);
- if (hasLandingStateUpdate) void saveState();
+ if (hasLandingStateUpdate || evt.autoDiscoveredMcpConfig !== undefined) void saveState();
}
}),
window.electron.ipcRenderer.on(IPC.MCP_TaskHydrated, (data: unknown) => {
@@ -1404,7 +1420,12 @@ function taskRequiresMcpLaunchArgs(taskId: string): boolean {
export function applyTaskMcpLaunchResult(
taskId: string,
- result: { mcpLaunchArgs?: string[] } | undefined,
+ result:
+ | {
+ mcpLaunchArgs?: string[];
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState | null;
+ }
+ | undefined,
): boolean {
if (!store.tasks[taskId]) return false;
const args = result?.mcpLaunchArgs;
@@ -1413,6 +1434,15 @@ export function applyTaskMcpLaunchResult(
return false;
}
if (Array.isArray(args)) setTaskMcpLaunchArgs(taskId, args);
+ if (result?.autoDiscoveredMcpConfig !== undefined) {
+ setStore(
+ 'tasks',
+ taskId,
+ 'autoDiscoveredMcpConfig',
+ result.autoDiscoveredMcpConfig ?? undefined,
+ );
+ void saveState();
+ }
markTaskMcpReady(taskId);
return true;
}
@@ -1474,7 +1504,10 @@ export function retryTaskMcpStartup(taskId: string): Promise {
return Promise.resolve();
}
const agentDef = task.agentIds[0] ? store.agents[task.agentIds[0]]?.def : undefined;
- return invoke<{ mcpLaunchArgs?: string[] }>(IPC.MCP_HydrateCoordinatedTask, {
+ return invoke<{
+ mcpLaunchArgs?: string[];
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState | null;
+ }>(IPC.MCP_HydrateCoordinatedTask, {
id: task.id,
name: task.name,
projectId: task.projectId,
@@ -1493,6 +1526,7 @@ export function retryTaskMcpStartup(taskId: string): Promise {
landingSummary: task.landingSummary,
landedMetadata: task.landedMetadata,
mcpConfigPath: task.mcpConfigPath,
+ autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig,
agentCommand: agentDef?.command ?? 'claude',
preambleFileExistedBefore: task.preambleFileExistedBefore,
})
diff --git a/src/store/types.ts b/src/store/types.ts
index e1040baf..d4fa4c40 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -9,6 +9,12 @@ export type KeybindingOverride = Partial>
export type GitIsolationMode = 'worktree' | 'direct' | 'none';
+export interface AutoDiscoveredMcpConfigState {
+ path: string;
+ previousParallelCode?: unknown;
+ writtenParallelCodeFingerprint: string;
+}
+
export interface StagedNotification {
batchId: string;
notificationIds: string[];
@@ -144,6 +150,7 @@ export interface Task {
controlledBy?: 'coordinator' | 'human';
automationWriteInFlight?: boolean;
mcpConfigPath?: string;
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState;
mcpLaunchArgs?: string[];
preambleFileExistedBefore?: boolean;
signalDoneReceived?: boolean;
@@ -204,6 +211,7 @@ export interface PersistedTask {
coordinatedBy?: string;
controlledBy?: 'coordinator' | 'human';
mcpConfigPath?: string;
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState;
preambleFileExistedBefore?: boolean;
signalDoneReceived?: boolean;
signalDoneAt?: string;
From 0cb12851eb37311b69acb2096f9dd7d9b72ff26c Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Wed, 5 Aug 2026 10:14:06 -0400
Subject: [PATCH 5/9] fix(mcp): fail closed on Kimi config restoration
---
docker/Dockerfile | 2 +-
electron/mcp/coordinator.test.ts | 98 +++++++++++++++++++++++++++++---
electron/mcp/coordinator.ts | 81 ++++++++++++++++++++------
electron/mcp/dockerfile.test.ts | 12 ++++
electron/mcp/types.ts | 1 +
src/store/types.ts | 1 +
6 files changed, 169 insertions(+), 26 deletions(-)
create mode 100644 electron/mcp/dockerfile.test.ts
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 19ce8f45..e18c46a6 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -50,7 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd 2>/dev/null || true
# AI agent CLIs — must be present so Docker-mode tasks can execute them
-RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai @moonshot-ai/kimi-code
+RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai @moonshot-ai/kimi-code@0.32.0
# Antigravity CLI (agy) — distributed as a Go binary via the official installer
# (not on npm). The installer's `--dir` flag drops the binary straight into a
diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts
index 6efd45a4..9b063829 100644
--- a/electron/mcp/coordinator.test.ts
+++ b/electron/mcp/coordinator.test.ts
@@ -1526,9 +1526,10 @@ describe('Coordinator land_self', () => {
it('restores a tracked Kimi .mcp.json before checking and merging the worktree', async () => {
const configPath = '/tmp/test/.mcp.json';
const previousParallelCode = { command: 'user-owned-server' };
- let currentConfig = JSON.stringify({
+ const originalConfig = JSON.stringify({
mcpServers: { 'parallel-code': previousParallelCode },
});
+ let currentConfig = originalConfig;
mockExistsSync.mockImplementation((path) => path === configPath);
mockReadFileSync.mockImplementation((path) =>
path === configPath ? currentConfig : '# existing\n',
@@ -1548,12 +1549,7 @@ describe('Coordinator land_self', () => {
return;
}
if (args[0] === 'status') {
- const config = JSON.parse(currentConfig) as {
- mcpServers: Record;
- };
- const restored = config.mcpServers['parallel-code'];
- const isRestored = JSON.stringify(restored) === JSON.stringify(previousParallelCode);
- cb(null, isRestored ? '' : ' M .mcp.json\n', '');
+ cb(null, currentConfig === originalConfig ? '' : ' M .mcp.json\n', '');
return;
}
if (args.join(' ') === 'rev-parse HEAD') {
@@ -1588,9 +1584,97 @@ describe('Coordinator land_self', () => {
const restored = JSON.parse(currentConfig) as { mcpServers: Record };
expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
+ expect(currentConfig).toBe(originalConfig);
expect(vi.mocked(mergeTask)).toHaveBeenCalled();
});
+ it('fails closed before self-landing when Kimi MCP restoration fingerprint mismatches', async () => {
+ const configPath = '/tmp/test/.mcp.json';
+ let currentConfig = JSON.stringify({
+ mcpServers: { 'parallel-code': { command: 'user-owned-server' } },
+ });
+ mockExistsSync.mockImplementation((path) => path === configPath);
+ mockReadFileSync.mockImplementation((path) =>
+ path === configPath ? currentConfig : '# existing\n',
+ );
+ mockAtomicWriteFileSync.mockImplementation((path, raw) => {
+ if (path === configPath) currentConfig = raw as string;
+ });
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-token',
+ 'subtask-token',
+ '/path/server.js',
+ );
+ await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
+
+ currentConfig = JSON.stringify({
+ mcpServers: { 'parallel-code': { command: 'changed-generated-entry' } },
+ });
+ mockExecFile.mockClear();
+
+ await expect(coordinator.landSelf('task-1', { verification })).rejects.toThrow(
+ 'Unable to restore managed Kimi MCP config',
+ );
+
+ expect(vi.mocked(mergeTask)).not.toHaveBeenCalled();
+ expect(mockExecFile).not.toHaveBeenCalledWith(
+ 'git',
+ expect.arrayContaining(['status']),
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(coordinator.getTask('task-1')?.autoDiscoveredMcpConfig).toBeDefined();
+ expect(coordinator.getTask('task-1')?.landingState).toBe('landing_escalated');
+ });
+
+ it('fails closed before merge staging when Kimi MCP restoration fingerprint mismatches', async () => {
+ const configPath = '/tmp/test/.mcp.json';
+ let currentConfig = JSON.stringify({
+ mcpServers: { 'parallel-code': { command: 'user-owned-server' } },
+ });
+ mockExistsSync.mockImplementation((path) => path === configPath);
+ mockReadFileSync.mockImplementation((path) =>
+ path === configPath ? currentConfig : '# existing\n',
+ );
+ mockAtomicWriteFileSync.mockImplementation((path, raw) => {
+ if (path === configPath) currentConfig = raw as string;
+ });
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-token',
+ 'subtask-token',
+ '/path/server.js',
+ );
+ const task = await coordinator.createTask({
+ name: 'test',
+ prompt: 'do',
+ coordinatorTaskId: 'coord-1',
+ });
+ task.signalDoneAt = new Date();
+ currentConfig = JSON.stringify({
+ mcpServers: { 'parallel-code': { command: 'changed-generated-entry' } },
+ });
+ mockExecFile.mockClear();
+
+ await expect(coordinator.mergeTask('task-1')).rejects.toThrow(
+ 'Unable to restore managed Kimi MCP config',
+ );
+
+ expect(mockExecFile).not.toHaveBeenCalledWith(
+ 'git',
+ expect.arrayContaining(['add', '-A']),
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(vi.mocked(mergeTask)).not.toHaveBeenCalled();
+ expect(coordinator.getTask('task-1')?.autoDiscoveredMcpConfig).toBeDefined();
+ });
+
it('stages a landed notification so the coordinator hears about successful self-land', async () => {
await coordinator.landSelf('task-1', { verification, summary: 'done' });
diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts
index 73e1b96a..c0b50d55 100644
--- a/electron/mcp/coordinator.ts
+++ b/electron/mcp/coordinator.ts
@@ -97,12 +97,10 @@ type McpJsonContent = Record & {
mcpServers?: Record;
};
-function readMcpJsonContent(configPath: string): McpJsonContent {
- if (!existsSync(configPath)) return {};
-
+function parseMcpJsonContent(configPath: string, raw: string): McpJsonContent {
let parsed: unknown;
try {
- parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
+ parsed = JSON.parse(raw);
} catch {
throw new Error(`${configPath} contains invalid JSON`);
}
@@ -121,6 +119,11 @@ function readMcpJsonContent(configPath: string): McpJsonContent {
return content;
}
+function readMcpJsonContent(configPath: string): McpJsonContent {
+ if (!existsSync(configPath)) return {};
+ return parseMcpJsonContent(configPath, readFileSync(configPath, 'utf-8'));
+}
+
function mcpEntryFingerprint(value: unknown): string {
return createHash('sha256')
.update(JSON.stringify(value) ?? 'undefined')
@@ -141,6 +144,7 @@ function validateAutoDiscoveredMcpConfigState(
return undefined;
return {
path: state.path,
+ previousContent: typeof state.previousContent === 'string' ? state.previousContent : undefined,
previousParallelCode: state.previousParallelCode,
writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint,
};
@@ -1577,7 +1581,9 @@ export class Coordinator {
const configPath = join(task.worktreePath, '.mcp.json');
const writtenParallelCode = mcpConfig.mcpServers['parallel-code'];
const priorState = task.autoDiscoveredMcpConfig;
- const content = readMcpJsonContent(configPath);
+ const existingContent = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : undefined;
+ const content =
+ existingContent === undefined ? {} : parseMcpJsonContent(configPath, existingContent);
const servers = content.mcpServers ?? {};
if (
@@ -1593,10 +1599,13 @@ export class Coordinator {
const previousParallelCode =
priorState?.path === configPath ? priorState.previousParallelCode : servers['parallel-code'];
+ const previousContent =
+ priorState?.path === configPath ? priorState.previousContent : existingContent;
content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode };
atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 });
task.autoDiscoveredMcpConfig = {
path: configPath,
+ previousContent,
previousParallelCode,
writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode),
};
@@ -1617,18 +1626,33 @@ export class Coordinator {
});
}
- private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): boolean {
+ private restoreTaskAutoDiscoveredMcpConfig(
+ task: CoordinatedTask,
+ ): 'none' | 'restored' | 'failed' {
const state = task.autoDiscoveredMcpConfig;
- task.autoDiscoveredMcpConfig = undefined;
- if (!state) return false;
- this.syncAutoDiscoveredMcpConfig(task);
+ if (!state) return 'none';
try {
- if (!existsSync(state.path)) return true;
+ if (!existsSync(state.path)) {
+ if (state.previousContent !== undefined) {
+ atomicWriteFileSync(state.path, state.previousContent, { mode: 0o600 });
+ }
+ task.autoDiscoveredMcpConfig = undefined;
+ this.syncAutoDiscoveredMcpConfig(task);
+ return 'restored';
+ }
+
const content = readMcpJsonContent(state.path);
const servers = content.mcpServers ?? {};
if (mcpEntryFingerprint(servers['parallel-code']) !== state.writtenParallelCodeFingerprint)
- return false;
+ return 'failed';
+
+ if (state.previousContent !== undefined) {
+ atomicWriteFileSync(state.path, state.previousContent, { mode: 0o600 });
+ task.autoDiscoveredMcpConfig = undefined;
+ this.syncAutoDiscoveredMcpConfig(task);
+ return 'restored';
+ }
if (state.previousParallelCode !== undefined) {
servers['parallel-code'] = state.previousParallelCode;
@@ -1640,15 +1664,23 @@ export class Coordinator {
const hasOtherKeys = Object.keys(content).some((key) => key !== 'mcpServers');
if (!hasServers && !hasOtherKeys) {
unlinkSync(state.path);
- return true;
+ task.autoDiscoveredMcpConfig = undefined;
+ this.syncAutoDiscoveredMcpConfig(task);
+ return 'restored';
}
if (hasServers) content.mcpServers = servers;
else delete content.mcpServers;
atomicWriteFileSync(state.path, JSON.stringify(content, null, 2), { mode: 0o600 });
- return true;
- } catch {
- // Best effort: malformed, concurrently removed, or inaccessible files are left untouched.
- return false;
+ task.autoDiscoveredMcpConfig = undefined;
+ this.syncAutoDiscoveredMcpConfig(task);
+ return 'restored';
+ } catch (err) {
+ logWarn('coordinator.kimi_mcp', 'failed to restore auto-discovered MCP config', {
+ taskId: task.id,
+ configPath: state.path,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ return 'failed';
}
}
@@ -1755,7 +1787,14 @@ export class Coordinator {
task.verification = input.verification;
task.landingSummary = input.summary;
- const shouldRefreshMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
+ const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
+ if (restoreMcpConfig === 'failed') {
+ const reason =
+ 'Unable to restore managed Kimi MCP config before self-landing; refusing to validate or merge a worktree that may contain ephemeral MCP tokens.';
+ this.escalateLanding(task, 'landing_escalated', reason);
+ throw new Error(reason);
+ }
+ const shouldRefreshMcpConfig = restoreMcpConfig === 'restored';
try {
await this.prepareCleanSelfLandingWorktree(task);
} catch (err) {
@@ -1851,7 +1890,13 @@ export class Coordinator {
const task = this.tasks.get(taskId);
if (!task) throw new Error(`Task not found: ${taskId}`);
this.assertTaskCanBeMerged(task);
- const shouldRefreshMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
+ const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
+ if (restoreMcpConfig === 'failed') {
+ throw new Error(
+ 'Unable to restore managed Kimi MCP config before merge; refusing to stage or merge a worktree that may contain ephemeral MCP tokens.',
+ );
+ }
+ const shouldRefreshMcpConfig = restoreMcpConfig === 'restored';
try {
// Strip injected preamble files before staging so they don't land in history,
diff --git a/electron/mcp/dockerfile.test.ts b/electron/mcp/dockerfile.test.ts
new file mode 100644
index 00000000..6da7dc34
--- /dev/null
+++ b/electron/mcp/dockerfile.test.ts
@@ -0,0 +1,12 @@
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { describe, expect, it } from 'vitest';
+
+describe('agent Dockerfile', () => {
+ it('pins Kimi Code below the workspace-trust-gated 0.33 line', () => {
+ const dockerfile = readFileSync(resolve(__dirname, '../../docker/Dockerfile'), 'utf8');
+
+ expect(dockerfile).toContain('@moonshot-ai/kimi-code@0.32.0');
+ expect(dockerfile).not.toContain('@moonshot-ai/kimi-code ');
+ });
+});
diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts
index 00f49e58..451bda09 100644
--- a/electron/mcp/types.ts
+++ b/electron/mcp/types.ts
@@ -2,6 +2,7 @@
export interface AutoDiscoveredMcpConfigState {
path: string;
+ previousContent?: string;
previousParallelCode?: unknown;
writtenParallelCodeFingerprint: string;
}
diff --git a/src/store/types.ts b/src/store/types.ts
index d4fa4c40..b3e967d5 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -11,6 +11,7 @@ export type GitIsolationMode = 'worktree' | 'direct' | 'none';
export interface AutoDiscoveredMcpConfigState {
path: string;
+ previousContent?: string;
previousParallelCode?: unknown;
writtenParallelCodeFingerprint: string;
}
From 3f91f459d723c34776508af4f2acf290a2f29f67 Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Thu, 6 Aug 2026 10:18:18 -0400
Subject: [PATCH 6/9] fix(mcp): keep Kimi tokens out of persisted history
---
electron/mcp/coordinator.test.ts | 81 ++++++++++++++++++----
electron/mcp/coordinator.ts | 114 +++++++++++++++++++++----------
electron/mcp/types.ts | 1 -
src/store/persistence.test.ts | 2 +-
src/store/tasks.test.ts | 2 +-
src/store/types.ts | 1 -
6 files changed, 147 insertions(+), 54 deletions(-)
diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts
index 9b063829..073c2702 100644
--- a/electron/mcp/coordinator.test.ts
+++ b/electron/mcp/coordinator.test.ts
@@ -1523,8 +1523,8 @@ describe('Coordinator land_self', () => {
);
});
- it('restores a tracked Kimi .mcp.json before checking and merging the worktree', async () => {
- const configPath = '/tmp/test/.mcp.json';
+ it('restores the Kimi auto-discovered config before checking and merging the worktree', async () => {
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
const previousParallelCode = { command: 'user-owned-server' };
const originalConfig = JSON.stringify({
mcpServers: { 'parallel-code': previousParallelCode },
@@ -1549,7 +1549,7 @@ describe('Coordinator land_self', () => {
return;
}
if (args[0] === 'status') {
- cb(null, currentConfig === originalConfig ? '' : ' M .mcp.json\n', '');
+ cb(null, '', '');
return;
}
if (args.join(' ') === 'rev-parse HEAD') {
@@ -1584,12 +1584,55 @@ describe('Coordinator land_self', () => {
const restored = JSON.parse(currentConfig) as { mcpServers: Record };
expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
- expect(currentConfig).toBe(originalConfig);
expect(vi.mocked(mergeTask)).toHaveBeenCalled();
});
+ it('fails closed before self-landing when a managed Kimi token is already in Git history', async () => {
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
+ let currentConfig = JSON.stringify({
+ mcpServers: { 'parallel-code': { command: 'user-owned-server' } },
+ });
+ mockExistsSync.mockImplementation((path) => path === configPath);
+ mockReadFileSync.mockImplementation((path) =>
+ path === configPath ? currentConfig : '# existing\n',
+ );
+ mockAtomicWriteFileSync.mockImplementation((path, raw) => {
+ if (path === configPath) currentConfig = raw as string;
+ });
+ mockExecFile.mockImplementation(
+ (
+ _cmd: string,
+ args: string[],
+ _opts: unknown,
+ cb: (err: Error | null, stdout: string, stderr: string) => void,
+ ) => {
+ if (args.join(' ').includes('-S subtask-token')) {
+ cb(null, 'secret-bearing-sha\n', '');
+ return;
+ }
+ cb(null, '', '');
+ },
+ );
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-token',
+ 'subtask-token',
+ '/path/server.js',
+ );
+ await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
+
+ await expect(coordinator.landSelf('task-1', { verification })).rejects.toThrow(
+ 'Managed Kimi MCP token was found in task Git history',
+ );
+
+ expect(vi.mocked(mergeTask)).not.toHaveBeenCalled();
+ expect(coordinator.getTask('task-1')?.landingState).toBe('landing_escalated');
+ });
+
it('fails closed before self-landing when Kimi MCP restoration fingerprint mismatches', async () => {
- const configPath = '/tmp/test/.mcp.json';
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
let currentConfig = JSON.stringify({
mcpServers: { 'parallel-code': { command: 'user-owned-server' } },
});
@@ -1631,7 +1674,7 @@ describe('Coordinator land_self', () => {
});
it('fails closed before merge staging when Kimi MCP restoration fingerprint mismatches', async () => {
- const configPath = '/tmp/test/.mcp.json';
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
let currentConfig = JSON.stringify({
mcpServers: { 'parallel-code': { command: 'user-owned-server' } },
});
@@ -3299,7 +3342,8 @@ describe('Coordinator sub-task MCP config isolation', () => {
await coordinator.createTask({ name: 'task-b', prompt: 'do b', coordinatorTaskId: 'coord-1' });
const childWrites = mockAtomicWriteFileSync.mock.calls.filter(
- ([configPath]) => configPath === '/tmp/a/.mcp.json' || configPath === '/tmp/b/.mcp.json',
+ ([configPath]) =>
+ configPath === '/tmp/a/.kimi-code/mcp.json' || configPath === '/tmp/b/.kimi-code/mcp.json',
);
expect(childWrites).toHaveLength(2);
const childConfigs = childWrites.map(
@@ -3321,8 +3365,8 @@ describe('Coordinator sub-task MCP config isolation', () => {
).not.toBe(childConfigs[1].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_DONE_TOKEN']);
expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith(
'/tmp/a',
- '.mcp.json',
- expect.stringContaining('.mcp.json'),
+ '.kimi-code/mcp.json',
+ expect.stringContaining('.kimi-code/mcp.json'),
expect.any(Function),
);
for (const [, spawnOpts] of mockSpawnAgent.mock.calls) {
@@ -3336,7 +3380,7 @@ describe('Coordinator sub-task MCP config isolation', () => {
});
it('restores a pre-existing Kimi child MCP entry when its coordinator deregisters', async () => {
- const configPath = '/tmp/test/.mcp.json';
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
const previousParallelCode = { command: 'user-owned-server' };
let currentConfig = JSON.stringify({
mcpServers: {
@@ -3362,19 +3406,26 @@ describe('Coordinator sub-task MCP config isolation', () => {
);
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
+ const concurrentlyEdited = JSON.parse(currentConfig) as {
+ mcpServers: Record;
+ setting: boolean | string;
+ };
+ concurrentlyEdited.mcpServers.other = { command: 'edited-server' };
+ concurrentlyEdited.setting = 'edited';
+ currentConfig = JSON.stringify(concurrentlyEdited);
coordinator.deregisterCoordinator('coord-1');
const restored = JSON.parse(currentConfig) as {
mcpServers: Record;
- setting: boolean;
+ setting: boolean | string;
};
expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
- expect(restored.mcpServers.other).toEqual({ command: 'other-server' });
- expect(restored.setting).toBe(true);
+ expect(restored.mcpServers.other).toEqual({ command: 'edited-server' });
+ expect(restored.setting).toBe('edited');
});
it('preserves the original Kimi entry across restart hydration and deregistration', async () => {
- const configPath = '/tmp/test/.mcp.json';
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
const previousParallelCode = { command: 'user-owned-server' };
let currentConfig = JSON.stringify({
mcpServers: {
@@ -3446,7 +3497,7 @@ describe('Coordinator sub-task MCP config isolation', () => {
});
it('does not overwrite a Kimi child MCP entry changed after creation', async () => {
- const configPath = '/tmp/test/.mcp.json';
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
let configExists = false;
let currentConfig = '';
mockExistsSync.mockImplementation((path) => path === configPath && configExists);
diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts
index c0b50d55..5c5d82b1 100644
--- a/electron/mcp/coordinator.ts
+++ b/electron/mcp/coordinator.ts
@@ -4,9 +4,9 @@
import { createHash, randomUUID, randomBytes } from 'crypto';
import { execFile } from 'child_process';
-import { join } from 'path';
+import { dirname, join } from 'path';
import { promisify } from 'util';
-import { unlinkSync, readFileSync, existsSync } from 'fs';
+import { mkdirSync, unlinkSync, readFileSync, existsSync } from 'fs';
import { unlink as fsUnlink } from 'fs/promises';
import {
buildSubTaskMcpConfig,
@@ -97,6 +97,11 @@ type McpJsonContent = Record & {
mcpServers?: Record;
};
+type RestoreMcpConfigResult = {
+ status: 'none' | 'restored' | 'failed';
+ managedEntry?: unknown;
+};
+
function parseMcpJsonContent(configPath: string, raw: string): McpJsonContent {
let parsed: unknown;
try {
@@ -136,7 +141,11 @@ function validateAutoDiscoveredMcpConfigState(
): AutoDiscoveredMcpConfigState | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const state = value as Record;
- if (state.path !== join(worktreePath, '.mcp.json')) return undefined;
+ const allowedPaths = [
+ join(worktreePath, '.mcp.json'),
+ join(worktreePath, '.kimi-code', 'mcp.json'),
+ ];
+ if (typeof state.path !== 'string' || !allowedPaths.includes(state.path)) return undefined;
if (
typeof state.writtenParallelCodeFingerprint !== 'string' ||
!/^[a-f0-9]{64}$/.test(state.writtenParallelCodeFingerprint)
@@ -144,7 +153,6 @@ function validateAutoDiscoveredMcpConfigState(
return undefined;
return {
path: state.path,
- previousContent: typeof state.previousContent === 'string' ? state.previousContent : undefined,
previousParallelCode: state.previousParallelCode,
writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint,
};
@@ -1578,7 +1586,7 @@ export class Coordinator {
): void {
if (!task.agentCommand || !isKimiCommand(task.agentCommand)) return;
- const configPath = join(task.worktreePath, '.mcp.json');
+ const configPath = join(task.worktreePath, '.kimi-code', 'mcp.json');
const writtenParallelCode = mcpConfig.mcpServers['parallel-code'];
const priorState = task.autoDiscoveredMcpConfig;
const existingContent = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : undefined;
@@ -1599,13 +1607,11 @@ export class Coordinator {
const previousParallelCode =
priorState?.path === configPath ? priorState.previousParallelCode : servers['parallel-code'];
- const previousContent =
- priorState?.path === configPath ? priorState.previousContent : existingContent;
content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode };
+ mkdirSync(dirname(configPath), { recursive: true });
atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 });
task.autoDiscoveredMcpConfig = {
path: configPath,
- previousContent,
previousParallelCode,
writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode),
};
@@ -1613,9 +1619,9 @@ export class Coordinator {
appendGitInfoExcludeBlock(
task.worktreePath,
- '.mcp.json',
- '# Parallel Code MCP config (contains ephemeral token)\n.mcp.json\n',
- (err) => console.warn('[MCP] Could not git-exclude child .mcp.json:', err),
+ '.kimi-code/mcp.json',
+ '# Parallel Code Kimi MCP config (contains ephemeral token)\n.kimi-code/mcp.json\n',
+ (err) => console.warn('[MCP] Could not git-exclude child Kimi MCP config:', err),
);
}
@@ -1626,33 +1632,22 @@ export class Coordinator {
});
}
- private restoreTaskAutoDiscoveredMcpConfig(
- task: CoordinatedTask,
- ): 'none' | 'restored' | 'failed' {
+ private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): RestoreMcpConfigResult {
const state = task.autoDiscoveredMcpConfig;
- if (!state) return 'none';
+ if (!state) return { status: 'none' };
try {
if (!existsSync(state.path)) {
- if (state.previousContent !== undefined) {
- atomicWriteFileSync(state.path, state.previousContent, { mode: 0o600 });
- }
task.autoDiscoveredMcpConfig = undefined;
this.syncAutoDiscoveredMcpConfig(task);
- return 'restored';
+ return { status: 'restored' };
}
const content = readMcpJsonContent(state.path);
const servers = content.mcpServers ?? {};
- if (mcpEntryFingerprint(servers['parallel-code']) !== state.writtenParallelCodeFingerprint)
- return 'failed';
-
- if (state.previousContent !== undefined) {
- atomicWriteFileSync(state.path, state.previousContent, { mode: 0o600 });
- task.autoDiscoveredMcpConfig = undefined;
- this.syncAutoDiscoveredMcpConfig(task);
- return 'restored';
- }
+ const managedEntry = servers['parallel-code'];
+ if (mcpEntryFingerprint(managedEntry) !== state.writtenParallelCodeFingerprint)
+ return { status: 'failed' };
if (state.previousParallelCode !== undefined) {
servers['parallel-code'] = state.previousParallelCode;
@@ -1666,21 +1661,50 @@ export class Coordinator {
unlinkSync(state.path);
task.autoDiscoveredMcpConfig = undefined;
this.syncAutoDiscoveredMcpConfig(task);
- return 'restored';
+ return { status: 'restored', managedEntry };
}
if (hasServers) content.mcpServers = servers;
else delete content.mcpServers;
atomicWriteFileSync(state.path, JSON.stringify(content, null, 2), { mode: 0o600 });
task.autoDiscoveredMcpConfig = undefined;
this.syncAutoDiscoveredMcpConfig(task);
- return 'restored';
+ return { status: 'restored', managedEntry };
} catch (err) {
logWarn('coordinator.kimi_mcp', 'failed to restore auto-discovered MCP config', {
taskId: task.id,
configPath: state.path,
error: err instanceof Error ? err.message : String(err),
});
- return 'failed';
+ return { status: 'failed' };
+ }
+ }
+
+ private extractManagedMcpTokens(managedEntry: unknown): string[] {
+ if (!managedEntry || typeof managedEntry !== 'object' || Array.isArray(managedEntry)) return [];
+ const env = (managedEntry as { env?: unknown }).env;
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return [];
+ return ['PARALLEL_CODE_MCP_TOKEN', 'PARALLEL_CODE_MCP_DONE_TOKEN']
+ .map((key) => (env as Record)[key])
+ .filter((value): value is string => typeof value === 'string' && value.length > 0);
+ }
+
+ private async assertManagedMcpTokensAbsentFromGitHistory(
+ task: CoordinatedTask,
+ managedEntry: unknown,
+ ): Promise {
+ const tokens = this.extractManagedMcpTokens(managedEntry);
+ for (const token of tokens) {
+ const result = await execAsync(
+ 'git',
+ ['log', '--all', '--format=%H', '-S', token, '--', '.mcp.json', '.kimi-code/mcp.json'],
+ { cwd: task.worktreePath },
+ );
+ const matches = execStdout(result).trim();
+ if (matches) {
+ throw new Error(
+ 'Managed Kimi MCP token was found in task Git history; refusing to land or merge until the token-bearing commit is removed.',
+ );
+ }
}
}
@@ -1788,13 +1812,24 @@ export class Coordinator {
task.landingSummary = input.summary;
const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
- if (restoreMcpConfig === 'failed') {
+ if (restoreMcpConfig.status === 'failed') {
const reason =
'Unable to restore managed Kimi MCP config before self-landing; refusing to validate or merge a worktree that may contain ephemeral MCP tokens.';
this.escalateLanding(task, 'landing_escalated', reason);
throw new Error(reason);
}
- const shouldRefreshMcpConfig = restoreMcpConfig === 'restored';
+ if (restoreMcpConfig.managedEntry !== undefined) {
+ try {
+ await this.assertManagedMcpTokensAbsentFromGitHistory(task, restoreMcpConfig.managedEntry);
+ } catch (err) {
+ if (restoreMcpConfig.status === 'restored')
+ this.refreshTaskMcpConfigAfterLandingFailure(task);
+ const reason = err instanceof Error ? err.message : String(err);
+ this.escalateLanding(task, 'landing_escalated', reason);
+ throw err;
+ }
+ }
+ const shouldRefreshMcpConfig = restoreMcpConfig.status === 'restored';
try {
await this.prepareCleanSelfLandingWorktree(task);
} catch (err) {
@@ -1891,12 +1926,21 @@ export class Coordinator {
if (!task) throw new Error(`Task not found: ${taskId}`);
this.assertTaskCanBeMerged(task);
const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
- if (restoreMcpConfig === 'failed') {
+ if (restoreMcpConfig.status === 'failed') {
throw new Error(
'Unable to restore managed Kimi MCP config before merge; refusing to stage or merge a worktree that may contain ephemeral MCP tokens.',
);
}
- const shouldRefreshMcpConfig = restoreMcpConfig === 'restored';
+ if (restoreMcpConfig.managedEntry !== undefined) {
+ try {
+ await this.assertManagedMcpTokensAbsentFromGitHistory(task, restoreMcpConfig.managedEntry);
+ } catch (err) {
+ if (restoreMcpConfig.status === 'restored')
+ this.refreshTaskMcpConfigAfterLandingFailure(task);
+ throw err;
+ }
+ }
+ const shouldRefreshMcpConfig = restoreMcpConfig.status === 'restored';
try {
// Strip injected preamble files before staging so they don't land in history,
diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts
index 451bda09..00f49e58 100644
--- a/electron/mcp/types.ts
+++ b/electron/mcp/types.ts
@@ -2,7 +2,6 @@
export interface AutoDiscoveredMcpConfigState {
path: string;
- previousContent?: string;
previousParallelCode?: unknown;
writtenParallelCodeFingerprint: string;
}
diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts
index 0c369ed9..5d4dc333 100644
--- a/src/store/persistence.test.ts
+++ b/src/store/persistence.test.ts
@@ -245,7 +245,7 @@ describe('landing state persistence', () => {
describe('Kimi auto-discovered MCP config persistence', () => {
const autoDiscoveredMcpConfig = {
- path: '/repo/.worktrees/task-1/.mcp.json',
+ path: '/repo/.worktrees/task-1/.kimi-code/mcp.json',
previousParallelCode: { command: 'user-owned-server' },
writtenParallelCodeFingerprint: 'a'.repeat(64),
};
diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts
index f646f5c2..e79b25e6 100644
--- a/src/store/tasks.test.ts
+++ b/src/store/tasks.test.ts
@@ -1330,7 +1330,7 @@ describe('MCP_TaskStateSync listener', () => {
it('stores and clears the auto-discovered MCP restoration snapshot', () => {
const snapshot = {
- path: '/repo/.worktrees/task-1/.mcp.json',
+ path: '/repo/.worktrees/task-1/.kimi-code/mcp.json',
previousParallelCode: { command: 'user-owned-server' },
writtenParallelCodeFingerprint: 'a'.repeat(64),
};
diff --git a/src/store/types.ts b/src/store/types.ts
index b3e967d5..d4fa4c40 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -11,7 +11,6 @@ export type GitIsolationMode = 'worktree' | 'direct' | 'none';
export interface AutoDiscoveredMcpConfigState {
path: string;
- previousContent?: string;
previousParallelCode?: unknown;
writtenParallelCodeFingerprint: string;
}
From 15080187aea9f1fdb7b27867b93731e01c9258cb Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Thu, 6 Aug 2026 12:34:29 -0400
Subject: [PATCH 7/9] fix(mcp): keep Kimi child credentials off tracked paths
---
electron/mcp/coordinator-test-harness.ts | 6 +
electron/mcp/coordinator.test.ts | 156 +++++++++++++++++++----
electron/mcp/coordinator.ts | 123 +++++++++++++-----
electron/mcp/types.ts | 1 -
src/store/persistence.test.ts | 1 -
src/store/tasks.test.ts | 1 -
src/store/types.ts | 1 -
7 files changed, 230 insertions(+), 59 deletions(-)
diff --git a/electron/mcp/coordinator-test-harness.ts b/electron/mcp/coordinator-test-harness.ts
index d48a4dee..8251d6b2 100644
--- a/electron/mcp/coordinator-test-harness.ts
+++ b/electron/mcp/coordinator-test-harness.ts
@@ -24,6 +24,7 @@ const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
const mocks = vi.hoisted(() => {
const mockExecFile = vi.fn();
+ const mockSpawnSync = vi.fn();
const mockWriteFileSync = vi.fn();
const mockReadFileSync = vi.fn();
const mockExistsSync = vi.fn();
@@ -56,6 +57,7 @@ const mocks = vi.hoisted(() => {
return {
mockExecFile,
+ mockSpawnSync,
mockWriteFileSync,
mockReadFileSync,
mockExistsSync,
@@ -90,6 +92,7 @@ const mocks = vi.hoisted(() => {
vi.mock('child_process', () => ({
execFile: mocks.mockExecFile,
+ spawnSync: mocks.mockSpawnSync,
}));
vi.mock('fs', () => ({
@@ -220,6 +223,7 @@ vi.mock('../log.js', () => ({
export const {
mockExecFile,
+ mockSpawnSync,
mockWriteFileSync,
mockReadFileSync,
mockExistsSync,
@@ -283,6 +287,8 @@ export function resetCoordinatorMocks(): void {
return { on: vi.fn() };
},
);
+ mockSpawnSync.mockReset();
+ mockSpawnSync.mockReturnValue({ status: 1, error: undefined, stderr: Buffer.alloc(0) });
mockWriteFileSync.mockReset();
mockReadFileSync.mockReset();
diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts
index 073c2702..1b2a9521 100644
--- a/electron/mcp/coordinator.test.ts
+++ b/electron/mcp/coordinator.test.ts
@@ -10,6 +10,7 @@ import type { MCPClient } from './client.js';
import {
setupCoordinatorHarness,
mockExecFile,
+ mockSpawnSync,
mockReadFileSync,
mockExistsSync,
mockUnlinkSync,
@@ -1525,9 +1526,8 @@ describe('Coordinator land_self', () => {
it('restores the Kimi auto-discovered config before checking and merging the worktree', async () => {
const configPath = '/tmp/test/.kimi-code/mcp.json';
- const previousParallelCode = { command: 'user-owned-server' };
const originalConfig = JSON.stringify({
- mcpServers: { 'parallel-code': previousParallelCode },
+ mcpServers: { other: { command: 'user-owned-server' } },
});
let currentConfig = originalConfig;
mockExistsSync.mockImplementation((path) => path === configPath);
@@ -1583,22 +1583,36 @@ describe('Coordinator land_self', () => {
await kimiCoordinator.landSelf('task-1', { verification });
const restored = JSON.parse(currentConfig) as { mcpServers: Record };
- expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
+ expect(restored.mcpServers['parallel-code']).toBeUndefined();
+ expect(restored.mcpServers.other).toEqual({ command: 'user-owned-server' });
expect(vi.mocked(mergeTask)).toHaveBeenCalled();
});
- it('fails closed before self-landing when a managed Kimi token is already in Git history', async () => {
+ it('fails closed on token-bearing history even when the discovery config was deleted', async () => {
const configPath = '/tmp/test/.kimi-code/mcp.json';
+ let autoConfigExists = true;
+ let taskConfig = '';
let currentConfig = JSON.stringify({
- mcpServers: { 'parallel-code': { command: 'user-owned-server' } },
+ mcpServers: { other: { command: 'user-owned-server' } },
+ });
+ mockExistsSync.mockImplementation(
+ (path) =>
+ (path === configPath && autoConfigExists) ||
+ (typeof path === 'string' && path.includes('parallel-code-subtask-')),
+ );
+ mockReadFileSync.mockImplementation((path) => {
+ if (path === configPath) return currentConfig;
+ if (typeof path === 'string' && path.includes('parallel-code-subtask-')) return taskConfig;
+ return '# existing\n';
});
- mockExistsSync.mockImplementation((path) => path === configPath);
- mockReadFileSync.mockImplementation((path) =>
- path === configPath ? currentConfig : '# existing\n',
- );
mockAtomicWriteFileSync.mockImplementation((path, raw) => {
if (path === configPath) currentConfig = raw as string;
});
+ mockAtomicWriteFile.mockImplementation(async (path, raw) => {
+ if (typeof path === 'string' && path.includes('parallel-code-subtask-')) {
+ taskConfig = raw as string;
+ }
+ });
mockExecFile.mockImplementation(
(
_cmd: string,
@@ -1606,8 +1620,8 @@ describe('Coordinator land_self', () => {
_opts: unknown,
cb: (err: Error | null, stdout: string, stderr: string) => void,
) => {
- if (args.join(' ').includes('-S subtask-token')) {
- cb(null, 'secret-bearing-sha\n', '');
+ if (args[0] === 'log') {
+ cb(null, '+ PARALLEL_CODE_MCP_TOKEN=subtask-token\n', '');
return;
}
cb(null, '', '');
@@ -1622,6 +1636,7 @@ describe('Coordinator land_self', () => {
'/path/server.js',
);
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
+ autoConfigExists = false;
await expect(coordinator.landSelf('task-1', { verification })).rejects.toThrow(
'Managed Kimi MCP token was found in task Git history',
@@ -1634,7 +1649,7 @@ describe('Coordinator land_self', () => {
it('fails closed before self-landing when Kimi MCP restoration fingerprint mismatches', async () => {
const configPath = '/tmp/test/.kimi-code/mcp.json';
let currentConfig = JSON.stringify({
- mcpServers: { 'parallel-code': { command: 'user-owned-server' } },
+ mcpServers: { other: { command: 'user-owned-server' } },
});
mockExistsSync.mockImplementation((path) => path === configPath);
mockReadFileSync.mockImplementation((path) =>
@@ -1676,7 +1691,7 @@ describe('Coordinator land_self', () => {
it('fails closed before merge staging when Kimi MCP restoration fingerprint mismatches', async () => {
const configPath = '/tmp/test/.kimi-code/mcp.json';
let currentConfig = JSON.stringify({
- mcpServers: { 'parallel-code': { command: 'user-owned-server' } },
+ mcpServers: { other: { command: 'user-owned-server' } },
});
mockExistsSync.mockImplementation((path) => path === configPath);
mockReadFileSync.mockImplementation((path) =>
@@ -3258,6 +3273,8 @@ describe('Coordinator sub-task MCP config isolation', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockSpawnSync.mockReset();
+ mockSpawnSync.mockReturnValue({ status: 1, error: undefined, stderr: Buffer.alloc(0) });
mockExistsSync.mockReturnValue(false);
coordinator = new Coordinator();
coordinator.setWindow(mockWin);
@@ -3379,13 +3396,100 @@ describe('Coordinator sub-task MCP config isolation', () => {
}
});
- it('restores a pre-existing Kimi child MCP entry when its coordinator deregisters', async () => {
+ it('uses the alternate Kimi discovery path when the preferred path is tracked', async () => {
+ mockSpawnSync.mockImplementation((_command: string, args: string[]) => ({
+ status: args[args.length - 1] === '.kimi-code/mcp.json' ? 0 : 1,
+ error: undefined,
+ stderr: Buffer.alloc(0),
+ }));
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-tok',
+ 'subtask-tok',
+ '/path/server.js',
+ );
+
+ const task = await coordinator.createTask({
+ name: 'test',
+ prompt: 'do',
+ coordinatorTaskId: 'coord-1',
+ });
+
+ expect(task.autoDiscoveredMcpConfig?.path).toBe('/tmp/test/.mcp.json');
+ expect(mockAtomicWriteFileSync).toHaveBeenCalledWith(
+ '/tmp/test/.mcp.json',
+ expect.stringContaining('subtask-tok'),
+ { mode: 0o600 },
+ );
+ });
+
+ it('fails task creation when both Kimi discovery paths are tracked', async () => {
+ mockSpawnSync.mockReturnValue({ status: 0, error: undefined, stderr: Buffer.alloc(0) });
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-tok',
+ 'subtask-tok',
+ '/path/server.js',
+ );
+
+ await expect(
+ coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }),
+ ).rejects.toThrow('both .kimi-code/mcp.json and .mcp.json are tracked by Git');
+
+ expect(mockAtomicWriteFileSync).not.toHaveBeenCalledWith(
+ expect.stringMatching(/(?:\.kimi-code\/mcp|\.mcp)\.json$/),
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(mockSpawnAgent).not.toHaveBeenCalled();
+ });
+
+ it('fails task creation instead of persisting a pre-existing parallel-code entry', async () => {
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
+ mockExistsSync.mockImplementation((path) => path === configPath);
+ mockReadFileSync.mockImplementation((path) =>
+ path === configPath
+ ? JSON.stringify({
+ mcpServers: {
+ 'parallel-code': {
+ command: 'user-owned-server',
+ env: { API_KEY: 'must-not-be-persisted' },
+ },
+ },
+ })
+ : '# existing\n',
+ );
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-tok',
+ 'subtask-tok',
+ '/path/server.js',
+ );
+
+ await expect(
+ coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }),
+ ).rejects.toThrow('already defines mcpServers["parallel-code"]');
+
+ expect(JSON.stringify(coordinator.getTask('task-1')) ?? '').not.toContain(
+ 'must-not-be-persisted',
+ );
+ expect(mockNotifyRenderer).not.toHaveBeenCalledWith(
+ 'mcp_task_created',
+ expect.objectContaining({ autoDiscoveredMcpConfig: expect.anything() }),
+ );
+ });
+
+ it('preserves concurrent Kimi config edits while removing its managed entry', async () => {
const configPath = '/tmp/test/.kimi-code/mcp.json';
- const previousParallelCode = { command: 'user-owned-server' };
let currentConfig = JSON.stringify({
mcpServers: {
other: { command: 'other-server' },
- 'parallel-code': previousParallelCode,
},
setting: true,
});
@@ -3419,18 +3523,16 @@ describe('Coordinator sub-task MCP config isolation', () => {
mcpServers: Record;
setting: boolean | string;
};
- expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
+ expect(restored.mcpServers['parallel-code']).toBeUndefined();
expect(restored.mcpServers.other).toEqual({ command: 'edited-server' });
expect(restored.setting).toBe('edited');
});
- it('preserves the original Kimi entry across restart hydration and deregistration', async () => {
+ it('persists only non-secret Kimi restoration metadata across restart hydration', async () => {
const configPath = '/tmp/test/.kimi-code/mcp.json';
- const previousParallelCode = { command: 'user-owned-server' };
let currentConfig = JSON.stringify({
mcpServers: {
other: { command: 'other-server' },
- 'parallel-code': previousParallelCode,
},
});
mockExistsSync.mockImplementation((path) => path === configPath);
@@ -3454,7 +3556,12 @@ describe('Coordinator sub-task MCP config isolation', () => {
coordinatorTaskId: 'coord-1',
});
const persistedState = task.autoDiscoveredMcpConfig;
- expect(persistedState?.previousParallelCode).toEqual(previousParallelCode);
+ expect(persistedState).toEqual({
+ path: configPath,
+ writtenParallelCodeFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
+ });
+ expect(JSON.stringify(persistedState)).not.toContain('old-subtask-token');
+ expect(JSON.stringify(persistedState)).not.toContain('other-server');
const restarted = new Coordinator();
restarted.setWindow(mockWin);
@@ -3481,7 +3588,10 @@ describe('Coordinator sub-task MCP config isolation', () => {
agentCommand: 'kimi',
});
- expect(result.autoDiscoveredMcpConfig?.previousParallelCode).toEqual(previousParallelCode);
+ expect(result.autoDiscoveredMcpConfig).toEqual({
+ path: configPath,
+ writtenParallelCodeFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
+ });
const refreshed = JSON.parse(currentConfig) as {
mcpServers: { 'parallel-code': { env: Record } };
};
@@ -3492,7 +3602,7 @@ describe('Coordinator sub-task MCP config isolation', () => {
restarted.deregisterCoordinator('coord-1');
const restored = JSON.parse(currentConfig) as { mcpServers: Record };
- expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode);
+ expect(restored.mcpServers['parallel-code']).toBeUndefined();
expect(restored.mcpServers.other).toEqual({ command: 'other-server' });
});
diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts
index 5c5d82b1..e219c5f3 100644
--- a/electron/mcp/coordinator.ts
+++ b/electron/mcp/coordinator.ts
@@ -3,7 +3,7 @@
// using existing backend primitives (pty, git, tasks).
import { createHash, randomUUID, randomBytes } from 'crypto';
-import { execFile } from 'child_process';
+import { execFile, spawnSync } from 'child_process';
import { dirname, join } from 'path';
import { promisify } from 'util';
import { mkdirSync, unlinkSync, readFileSync, existsSync } from 'fs';
@@ -92,6 +92,7 @@ const PREAMBLE_ARTIFACT_PATHS = new Set([
'.claude/settings.local.json',
]);
const UNRESOLVED_LANDED_COMMIT = 'unresolved';
+const KIMI_AUTO_DISCOVERED_MCP_PATHS = ['.kimi-code/mcp.json', '.mcp.json'] as const;
type McpJsonContent = Record & {
mcpServers?: Record;
@@ -135,16 +136,26 @@ function mcpEntryFingerprint(value: unknown): string {
.digest('hex');
}
+function isTrackedGitPath(worktreePath: string, relativePath: string): boolean {
+ const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', relativePath], {
+ cwd: worktreePath,
+ stdio: 'ignore',
+ });
+ if (result.error) {
+ throw new Error(`Unable to verify whether ${relativePath} is tracked: ${result.error.message}`);
+ }
+ if (result.status === 0) return true;
+ if (result.status === 1) return false;
+ throw new Error(`Unable to verify whether ${relativePath} is tracked`);
+}
+
function validateAutoDiscoveredMcpConfigState(
value: unknown,
worktreePath: string,
): AutoDiscoveredMcpConfigState | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const state = value as Record;
- const allowedPaths = [
- join(worktreePath, '.mcp.json'),
- join(worktreePath, '.kimi-code', 'mcp.json'),
- ];
+ const allowedPaths = KIMI_AUTO_DISCOVERED_MCP_PATHS.map((path) => join(worktreePath, path));
if (typeof state.path !== 'string' || !allowedPaths.includes(state.path)) return undefined;
if (
typeof state.writtenParallelCodeFingerprint !== 'string' ||
@@ -153,7 +164,6 @@ function validateAutoDiscoveredMcpConfigState(
return undefined;
return {
path: state.path,
- previousParallelCode: state.previousParallelCode,
writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint,
};
}
@@ -1586,12 +1596,43 @@ export class Coordinator {
): void {
if (!task.agentCommand || !isKimiCommand(task.agentCommand)) return;
- const configPath = join(task.worktreePath, '.kimi-code', 'mcp.json');
const writtenParallelCode = mcpConfig.mcpServers['parallel-code'];
const priorState = task.autoDiscoveredMcpConfig;
- const existingContent = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : undefined;
- const content =
- existingContent === undefined ? {} : parseMcpJsonContent(configPath, existingContent);
+ const candidates = KIMI_AUTO_DISCOVERED_MCP_PATHS.map((relativePath) => {
+ const configPath = join(task.worktreePath, relativePath);
+ return {
+ relativePath,
+ configPath,
+ tracked: isTrackedGitPath(task.worktreePath, relativePath),
+ content: readMcpJsonContent(configPath),
+ };
+ });
+
+ for (const candidate of candidates) {
+ const existingParallelCode = candidate.content.mcpServers?.['parallel-code'];
+ const isManagedCandidate = priorState?.path === candidate.configPath;
+ if (existingParallelCode !== undefined && !isManagedCandidate) {
+ throw new Error(
+ `Unable to create Kimi child MCP config: ${candidate.relativePath} already defines mcpServers["parallel-code"].`,
+ );
+ }
+ }
+
+ const candidate = priorState
+ ? candidates.find(({ configPath }) => configPath === priorState.path)
+ : candidates.find(({ tracked }) => !tracked);
+ if (!candidate) {
+ throw new Error(
+ 'Unable to create Kimi child MCP config: both .kimi-code/mcp.json and .mcp.json are tracked by Git.',
+ );
+ }
+ if (candidate.tracked) {
+ throw new Error(
+ `Unable to create Kimi child MCP config: ${candidate.relativePath} is tracked by Git.`,
+ );
+ }
+
+ const { configPath, content, relativePath } = candidate;
const servers = content.mcpServers ?? {};
if (
@@ -1605,22 +1646,19 @@ export class Coordinator {
return;
}
- const previousParallelCode =
- priorState?.path === configPath ? priorState.previousParallelCode : servers['parallel-code'];
content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode };
mkdirSync(dirname(configPath), { recursive: true });
atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 });
task.autoDiscoveredMcpConfig = {
path: configPath,
- previousParallelCode,
writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode),
};
this.syncAutoDiscoveredMcpConfig(task);
appendGitInfoExcludeBlock(
task.worktreePath,
- '.kimi-code/mcp.json',
- '# Parallel Code Kimi MCP config (contains ephemeral token)\n.kimi-code/mcp.json\n',
+ relativePath,
+ `# Parallel Code Kimi MCP config (contains ephemeral token)\n${relativePath}\n`,
(err) => console.warn('[MCP] Could not git-exclude child Kimi MCP config:', err),
);
}
@@ -1632,15 +1670,37 @@ export class Coordinator {
});
}
+ private readManagedMcpEntryFromTaskConfig(
+ task: CoordinatedTask,
+ state: AutoDiscoveredMcpConfigState,
+ ): unknown {
+ if (!task.mcpConfigPath || !existsSync(task.mcpConfigPath)) return undefined;
+ try {
+ const entry = readMcpJsonContent(task.mcpConfigPath).mcpServers?.['parallel-code'];
+ return mcpEntryFingerprint(entry) === state.writtenParallelCodeFingerprint
+ ? entry
+ : undefined;
+ } catch (err) {
+ logWarn('coordinator.kimi_mcp', 'failed to read per-task MCP config for history check', {
+ taskId: task.id,
+ configPath: task.mcpConfigPath,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ return undefined;
+ }
+ }
+
private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): RestoreMcpConfigResult {
const state = task.autoDiscoveredMcpConfig;
if (!state) return { status: 'none' };
try {
if (!existsSync(state.path)) {
+ const managedEntry = this.readManagedMcpEntryFromTaskConfig(task, state);
+ if (managedEntry === undefined) return { status: 'failed' };
task.autoDiscoveredMcpConfig = undefined;
this.syncAutoDiscoveredMcpConfig(task);
- return { status: 'restored' };
+ return { status: 'restored', managedEntry };
}
const content = readMcpJsonContent(state.path);
@@ -1649,11 +1709,7 @@ export class Coordinator {
if (mcpEntryFingerprint(managedEntry) !== state.writtenParallelCodeFingerprint)
return { status: 'failed' };
- if (state.previousParallelCode !== undefined) {
- servers['parallel-code'] = state.previousParallelCode;
- } else {
- delete servers['parallel-code'];
- }
+ delete servers['parallel-code'];
const hasServers = Object.keys(servers).length > 0;
const hasOtherKeys = Object.keys(content).some((key) => key !== 'mcpServers');
@@ -1693,18 +1749,21 @@ export class Coordinator {
managedEntry: unknown,
): Promise {
const tokens = this.extractManagedMcpTokens(managedEntry);
- for (const token of tokens) {
- const result = await execAsync(
- 'git',
- ['log', '--all', '--format=%H', '-S', token, '--', '.mcp.json', '.kimi-code/mcp.json'],
- { cwd: task.worktreePath },
+ if (tokens.length === 0) {
+ throw new Error(
+ 'Unable to verify managed Kimi MCP tokens before landing or merge; refusing to continue.',
+ );
+ }
+ const result = await execAsync(
+ 'git',
+ ['log', '--all', '-p', '--format=', '--', '.mcp.json', '.kimi-code/mcp.json'],
+ { cwd: task.worktreePath },
+ );
+ const history = execStdout(result);
+ if (tokens.some((token) => history.includes(token))) {
+ throw new Error(
+ 'Managed Kimi MCP token was found in task Git history; refusing to land or merge until the token-bearing commit is removed.',
);
- const matches = execStdout(result).trim();
- if (matches) {
- throw new Error(
- 'Managed Kimi MCP token was found in task Git history; refusing to land or merge until the token-bearing commit is removed.',
- );
- }
}
}
diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts
index 00f49e58..1d0b0339 100644
--- a/electron/mcp/types.ts
+++ b/electron/mcp/types.ts
@@ -2,7 +2,6 @@
export interface AutoDiscoveredMcpConfigState {
path: string;
- previousParallelCode?: unknown;
writtenParallelCodeFingerprint: string;
}
diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts
index 5d4dc333..4d7570f3 100644
--- a/src/store/persistence.test.ts
+++ b/src/store/persistence.test.ts
@@ -246,7 +246,6 @@ describe('landing state persistence', () => {
describe('Kimi auto-discovered MCP config persistence', () => {
const autoDiscoveredMcpConfig = {
path: '/repo/.worktrees/task-1/.kimi-code/mcp.json',
- previousParallelCode: { command: 'user-owned-server' },
writtenParallelCodeFingerprint: 'a'.repeat(64),
};
diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts
index e79b25e6..fee12d11 100644
--- a/src/store/tasks.test.ts
+++ b/src/store/tasks.test.ts
@@ -1331,7 +1331,6 @@ describe('MCP_TaskStateSync listener', () => {
it('stores and clears the auto-discovered MCP restoration snapshot', () => {
const snapshot = {
path: '/repo/.worktrees/task-1/.kimi-code/mcp.json',
- previousParallelCode: { command: 'user-owned-server' },
writtenParallelCodeFingerprint: 'a'.repeat(64),
};
diff --git a/src/store/types.ts b/src/store/types.ts
index d4fa4c40..bffa04ee 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -11,7 +11,6 @@ export type GitIsolationMode = 'worktree' | 'direct' | 'none';
export interface AutoDiscoveredMcpConfigState {
path: string;
- previousParallelCode?: unknown;
writtenParallelCodeFingerprint: string;
}
From 7fc70ae88a7a6e366235d0a4f9e1e1ea3e6aa17d Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Fri, 7 Aug 2026 10:10:30 -0400
Subject: [PATCH 8/9] fix(mcp): write Kimi child preamble to AGENTS
---
electron/mcp/preamble.test.ts | 25 +++++++++++++++++++++++++
electron/mcp/preamble.ts | 2 +-
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/electron/mcp/preamble.test.ts b/electron/mcp/preamble.test.ts
index ce6e2c7c..fccd40e1 100644
--- a/electron/mcp/preamble.test.ts
+++ b/electron/mcp/preamble.test.ts
@@ -34,6 +34,31 @@ describe('sub-task preamble injection', () => {
}
});
+ it('writes Kimi child preambles to AGENTS.md instead of Claude settings', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'parallel-code-preamble-test-'));
+ const agentsPath = join(dir, 'AGENTS.md');
+ const settingsPath = join(dir, '.claude', 'settings.local.json');
+ const queue = new Map>();
+
+ try {
+ const injected = await injectSubTaskPreamble({
+ worktreePath: dir,
+ agentCommand: 'kimi-code --yolo',
+ queue,
+ });
+
+ expect(injected).toMatchObject({
+ filePath: agentsPath,
+ existedBefore: false,
+ restoreOnFailure: true,
+ });
+ expect(readFileSync(agentsPath, 'utf8')).toContain('');
+ expect(existsSync(settingsPath)).toBe(false);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
it('writes Claude settings.local.json without making it a failure-restore target', async () => {
const dir = mkdtempSync(join(tmpdir(), 'parallel-code-preamble-test-'));
const settingsPath = join(dir, '.claude', 'settings.local.json');
diff --git a/electron/mcp/preamble.ts b/electron/mcp/preamble.ts
index 670ad27e..f34a0a73 100644
--- a/electron/mcp/preamble.ts
+++ b/electron/mcp/preamble.ts
@@ -85,7 +85,7 @@ export async function injectSubTaskPreamble(args: {
queue: PreambleWriteQueue;
}): Promise {
const agentCmd = args.agentCommand.toLowerCase();
- if (agentCmd.includes('codex') || agentCmd.includes('opencode')) {
+ if (agentCmd.includes('codex') || agentCmd.includes('opencode') || agentCmd.includes('kimi')) {
return injectMarkdownPreamble(args.queue, join(args.worktreePath, 'AGENTS.md'));
}
if (agentCmd.includes('gemini')) {
From 061ec6763fd69fd341767dead03eb8f853122dcd Mon Sep 17 00:00:00 2001
From: Liang Hu
Date: Fri, 14 Aug 2026 10:07:50 -0400
Subject: [PATCH 9/9] fix(mcp): harden Kimi config refresh
---
docker/Dockerfile | 1 +
electron/mcp/coordinator.test.ts | 58 ++++++++++++++++++++++++++++++++
electron/mcp/coordinator.ts | 35 ++++++++++---------
electron/mcp/dockerfile.test.ts | 2 +-
electron/mcp/preamble.test.ts | 2 +-
5 files changed, 81 insertions(+), 17 deletions(-)
diff --git a/docker/Dockerfile b/docker/Dockerfile
index e18c46a6..1063cfad 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -50,6 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd 2>/dev/null || true
# AI agent CLIs — must be present so Docker-mode tasks can execute them
+# Keep Kimi below 0.33: newer releases block fresh worktrees on workspace trust.
RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai @moonshot-ai/kimi-code@0.32.0
# Antigravity CLI (agy) — distributed as a Go binary via the official installer
diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts
index 5cf77cd4..abbd3461 100644
--- a/electron/mcp/coordinator.test.ts
+++ b/electron/mcp/coordinator.test.ts
@@ -3434,6 +3434,64 @@ describe('Coordinator sub-task MCP config isolation', () => {
);
});
+ it('only parses the selected Kimi discovery path', async () => {
+ mockSpawnSync.mockImplementation((_command: string, args: string[]) => ({
+ status: args[args.length - 1] === '.mcp.json' ? 0 : 1,
+ error: undefined,
+ stderr: Buffer.alloc(0),
+ }));
+ mockExistsSync.mockImplementation((path) => path === '/tmp/test/.mcp.json');
+ mockReadFileSync.mockImplementation((path) => {
+ if (path === '/tmp/test/.mcp.json') throw new Error('unused candidate must not be parsed');
+ return '# existing\n';
+ });
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-tok',
+ 'subtask-tok',
+ '/path/server.js',
+ );
+
+ await expect(
+ coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }),
+ ).resolves.toBeDefined();
+ expect(mockReadFileSync).not.toHaveBeenCalledWith('/tmp/test/.mcp.json', 'utf-8');
+ });
+
+ it('keeps restarting sibling Kimi configs after one task refresh fails', async () => {
+ coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3001',
+ 'coordinator-tok',
+ 'subtask-tok',
+ '/path/server.js',
+ );
+ await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
+ mockSpawnSync.mockImplementation(() => ({
+ status: null,
+ error: new Error('worktree disappeared'),
+ stderr: Buffer.alloc(0),
+ }));
+
+ expect(() =>
+ coordinator.setMCPServerInfo(
+ 'coord-1',
+ 'http://localhost:3002',
+ 'coordinator-tok-2',
+ 'subtask-tok-2',
+ '/path/server.js',
+ ),
+ ).not.toThrow();
+ expect(mockLogWarn).toHaveBeenCalledWith(
+ 'coordinator.kimi_mcp',
+ 'failed to refresh Kimi child MCP config',
+ expect.objectContaining({ taskId: 'task-1' }),
+ );
+ });
+
it('fails task creation when both Kimi discovery paths are tracked', async () => {
mockSpawnSync.mockReturnValue({ status: 0, error: undefined, stderr: Buffer.alloc(0) });
coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []);
diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts
index 88224f76..db7b5bf1 100644
--- a/electron/mcp/coordinator.ts
+++ b/electron/mcp/coordinator.ts
@@ -687,7 +687,14 @@ export class Coordinator {
doneToken: task.doneToken,
});
writeSubTaskMcpConfigSync(mcpConfigPath, mcpConfig);
- this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig);
+ try {
+ this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig);
+ } catch (err) {
+ logWarn('coordinator.kimi_mcp', 'failed to refresh Kimi child MCP config', {
+ taskId: task.id,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
}
}
@@ -1614,20 +1621,9 @@ export class Coordinator {
relativePath,
configPath,
tracked: isTrackedGitPath(task.worktreePath, relativePath),
- content: readMcpJsonContent(configPath),
};
});
- for (const candidate of candidates) {
- const existingParallelCode = candidate.content.mcpServers?.['parallel-code'];
- const isManagedCandidate = priorState?.path === candidate.configPath;
- if (existingParallelCode !== undefined && !isManagedCandidate) {
- throw new Error(
- `Unable to create Kimi child MCP config: ${candidate.relativePath} already defines mcpServers["parallel-code"].`,
- );
- }
- }
-
const candidate = priorState
? candidates.find(({ configPath }) => configPath === priorState.path)
: candidates.find(({ tracked }) => !tracked);
@@ -1642,7 +1638,15 @@ export class Coordinator {
);
}
- const { configPath, content, relativePath } = candidate;
+ const { configPath, relativePath } = candidate;
+ const content = readMcpJsonContent(configPath);
+ const existingParallelCode = content.mcpServers?.['parallel-code'];
+ const isManagedCandidate = priorState?.path === configPath;
+ if (existingParallelCode !== undefined && !isManagedCandidate) {
+ throw new Error(
+ `Unable to create Kimi child MCP config: ${relativePath} already defines mcpServers["parallel-code"].`,
+ );
+ }
const servers = content.mcpServers ?? {};
if (
@@ -1764,10 +1768,11 @@ export class Coordinator {
'Unable to verify managed Kimi MCP tokens before landing or merge; refusing to continue.',
);
}
+ const historyRange = task.baseBranch ? `${task.baseBranch}..HEAD` : 'HEAD';
const result = await execAsync(
'git',
- ['log', '--all', '-p', '--format=', '--', '.mcp.json', '.kimi-code/mcp.json'],
- { cwd: task.worktreePath },
+ ['log', historyRange, '-p', '--format=', '--', '.mcp.json', '.kimi-code/mcp.json'],
+ { cwd: task.worktreePath, maxBuffer: 8 * 1024 * 1024 },
);
const history = execStdout(result);
if (tokens.some((token) => history.includes(token))) {
diff --git a/electron/mcp/dockerfile.test.ts b/electron/mcp/dockerfile.test.ts
index 6da7dc34..2f20a11e 100644
--- a/electron/mcp/dockerfile.test.ts
+++ b/electron/mcp/dockerfile.test.ts
@@ -6,7 +6,7 @@ describe('agent Dockerfile', () => {
it('pins Kimi Code below the workspace-trust-gated 0.33 line', () => {
const dockerfile = readFileSync(resolve(__dirname, '../../docker/Dockerfile'), 'utf8');
+ expect(dockerfile).toContain('# Keep Kimi below 0.33');
expect(dockerfile).toContain('@moonshot-ai/kimi-code@0.32.0');
- expect(dockerfile).not.toContain('@moonshot-ai/kimi-code ');
});
});
diff --git a/electron/mcp/preamble.test.ts b/electron/mcp/preamble.test.ts
index fccd40e1..3fde21a3 100644
--- a/electron/mcp/preamble.test.ts
+++ b/electron/mcp/preamble.test.ts
@@ -43,7 +43,7 @@ describe('sub-task preamble injection', () => {
try {
const injected = await injectSubTaskPreamble({
worktreePath: dir,
- agentCommand: 'kimi-code --yolo',
+ agentCommand: 'kimi',
queue,
});