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..1063cfad 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -50,7 +50,8 @@ 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
+# 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
# (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 e59f2d1d..4737200c 100644
--- a/electron/ipc/pty.test.ts
+++ b/electron/ipc/pty.test.ts
@@ -431,6 +431,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 60abfa4c..3b2054e9 100644
--- a/electron/ipc/pty.ts
+++ b/electron/ipc/pty.ts
@@ -779,6 +779,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/ipc/register.ts b/electron/ipc/register.ts
index ab8d213f..8e48ac99 100644
--- a/electron/ipc/register.ts
+++ b/electron/ipc/register.ts
@@ -1511,6 +1511,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;
@@ -1549,6 +1550,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/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/electron/mcp/coordinator-test-harness.ts b/electron/mcp/coordinator-test-harness.ts
index 31d84bad..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();
@@ -36,6 +37,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();
@@ -55,6 +57,7 @@ const mocks = vi.hoisted(() => {
return {
mockExecFile,
+ mockSpawnSync,
mockWriteFileSync,
mockReadFileSync,
mockExistsSync,
@@ -67,6 +70,7 @@ const mocks = vi.hoisted(() => {
mockFsMkdir,
mockAtomicWriteFileSync,
mockAtomicWriteFile,
+ mockAppendGitInfoExcludeBlock,
mockNotifyRenderer,
mockLogInfo,
mockLogWarn,
@@ -88,6 +92,7 @@ const mocks = vi.hoisted(() => {
vi.mock('child_process', () => ({
execFile: mocks.mockExecFile,
+ spawnSync: mocks.mockSpawnSync,
}));
vi.mock('fs', () => ({
@@ -111,6 +116,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(
@@ -214,6 +223,7 @@ vi.mock('../log.js', () => ({
export const {
mockExecFile,
+ mockSpawnSync,
mockWriteFileSync,
mockReadFileSync,
mockExistsSync,
@@ -226,6 +236,7 @@ export const {
mockFsMkdir,
mockAtomicWriteFileSync,
mockAtomicWriteFile,
+ mockAppendGitInfoExcludeBlock,
mockNotifyRenderer,
mockLogInfo,
mockLogWarn,
@@ -276,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();
@@ -299,6 +312,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 7d20b552..abbd3461 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,
@@ -17,8 +18,10 @@ import {
mockFsAccess,
mockAtomicWriteFileSync,
mockAtomicWriteFile,
+ mockAppendGitInfoExcludeBlock,
mockNotifyRenderer,
mockLogInfo,
+ mockLogWarn,
mockSpawnAgent,
mockWriteToAgent,
mockSubscribeToAgent,
@@ -1521,6 +1524,215 @@ 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 originalConfig = JSON.stringify({
+ mcpServers: { other: { command: 'user-owned-server' } },
+ });
+ let currentConfig = originalConfig;
+ 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') {
+ cb(null, '', '');
+ 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']).toBeUndefined();
+ expect(restored.mcpServers.other).toEqual({ command: 'user-owned-server' });
+ expect(vi.mocked(mergeTask)).toHaveBeenCalled();
+ });
+
+ 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: { 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';
+ });
+ 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,
+ args: string[],
+ _opts: unknown,
+ cb: (err: Error | null, stdout: string, stderr: string) => void,
+ ) => {
+ if (args[0] === 'log') {
+ cb(null, '+ PARALLEL_CODE_MCP_TOKEN=subtask-token\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' });
+ autoConfigExists = false;
+
+ 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/.kimi-code/mcp.json';
+ let currentConfig = JSON.stringify({
+ mcpServers: { other: { 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/.kimi-code/mcp.json';
+ let currentConfig = JSON.stringify({
+ mcpServers: { other: { 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' });
@@ -3070,6 +3282,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);
@@ -3136,6 +3350,377 @@ 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/.kimi-code/mcp.json' || configPath === '/tmp/b/.kimi-code/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',
+ '.kimi-code/mcp.json',
+ expect.stringContaining('.kimi-code/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('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('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', []);
+ 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';
+ let currentConfig = JSON.stringify({
+ mcpServers: {
+ other: { command: 'other-server' },
+ },
+ 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' });
+ 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 | string;
+ };
+ expect(restored.mcpServers['parallel-code']).toBeUndefined();
+ expect(restored.mcpServers.other).toEqual({ command: 'edited-server' });
+ expect(restored.setting).toBe('edited');
+ });
+
+ it('persists only non-secret Kimi restoration metadata across restart hydration', async () => {
+ const configPath = '/tmp/test/.kimi-code/mcp.json';
+ let currentConfig = JSON.stringify({
+ mcpServers: {
+ other: { command: 'other-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',
+ '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).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);
+ 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).toEqual({
+ path: configPath,
+ writtenParallelCodeFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
+ });
+ 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']).toBeUndefined();
+ 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/.kimi-code/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 ────────────────────────────────────────
@@ -3418,6 +4003,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 4e8a1661..db7b5bf1 100644
--- a/electron/mcp/coordinator.ts
+++ b/electron/mcp/coordinator.ts
@@ -2,10 +2,11 @@
// Manages task lifecycle independently of the SolidJS renderer,
// using existing backend primitives (pty, git, tasks).
-import { randomUUID, randomBytes } from 'crypto';
-import { execFile } from 'child_process';
+import { createHash, randomUUID, randomBytes } from 'crypto';
+import { execFile, spawnSync } from 'child_process';
+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,
@@ -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,
@@ -64,6 +66,7 @@ import type {
ApiTaskDetail,
ApiDiffResult,
ApiLandSelfResult,
+ AutoDiscoveredMcpConfigState,
LandSelfInput,
LandingState,
SubtaskVerification,
@@ -89,6 +92,81 @@ 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;
+};
+
+type RestoreMcpConfigResult = {
+ status: 'none' | 'restored' | 'failed';
+ managedEntry?: unknown;
+};
+
+function parseMcpJsonContent(configPath: string, raw: string): McpJsonContent {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } 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 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')
+ .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 = 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' ||
+ !/^[a-f0-9]{64}$/.test(state.writtenParallelCodeFingerprint)
+ )
+ return undefined;
+ return {
+ path: state.path,
+ writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint,
+ };
+}
function pasteDelayMs(text: string): number {
const lines = text.split('\n').length;
@@ -609,6 +687,14 @@ export class Coordinator {
doneToken: task.doneToken,
});
writeSubTaskMcpConfigSync(mcpConfigPath, 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),
+ });
+ }
}
}
@@ -876,6 +962,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;
@@ -911,6 +998,7 @@ export class Coordinator {
await writeSubTaskMcpConfig(configPath, mcpConfig);
subTaskMcpConfigPath = configPath;
task.mcpConfigPath = configPath;
+ this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig);
}
const agentArgs = opts.agentArgs ?? coordinatorState.spawnDefaults.args;
@@ -975,6 +1063,7 @@ export class Coordinator {
agentId: task.agentId,
coordinatorTaskId: task.coordinatorTaskId,
mcpConfigPath: subTaskMcpConfigPath,
+ autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig,
prompt: task.initialPrompt,
preambleFileExistedBefore: task.preambleFileExistedBefore,
agentCommand: agentCommand,
@@ -1518,6 +1607,197 @@ export class Coordinator {
this.clearAgentBuffers(task.agentId);
}
+ private writeKimiAutoDiscoveredMcpConfig(
+ task: CoordinatedTask,
+ mcpConfig: ReturnType,
+ ): void {
+ if (!task.agentCommand || !isKimiCommand(task.agentCommand)) return;
+
+ const writtenParallelCode = mcpConfig.mcpServers['parallel-code'];
+ const priorState = task.autoDiscoveredMcpConfig;
+ const candidates = KIMI_AUTO_DISCOVERED_MCP_PATHS.map((relativePath) => {
+ const configPath = join(task.worktreePath, relativePath);
+ return {
+ relativePath,
+ configPath,
+ tracked: isTrackedGitPath(task.worktreePath, relativePath),
+ };
+ });
+
+ 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, 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 (
+ priorState?.path === configPath &&
+ mcpEntryFingerprint(servers['parallel-code']) !== priorState.writtenParallelCodeFingerprint
+ ) {
+ logWarn('coordinator.kimi_mcp', 'auto-discovered MCP config changed; refusing overwrite', {
+ taskId: task.id,
+ configPath,
+ });
+ return;
+ }
+
+ content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode };
+ mkdirSync(dirname(configPath), { recursive: true });
+ atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 });
+ task.autoDiscoveredMcpConfig = {
+ path: configPath,
+ writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode),
+ };
+ this.syncAutoDiscoveredMcpConfig(task);
+
+ appendGitInfoExcludeBlock(
+ task.worktreePath,
+ 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),
+ );
+ }
+
+ private syncAutoDiscoveredMcpConfig(task: CoordinatedTask): void {
+ this.notifyRenderer(IPC.MCP_TaskStateSync, {
+ taskId: task.id,
+ autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig ?? null,
+ });
+ }
+
+ 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', managedEntry };
+ }
+
+ const content = readMcpJsonContent(state.path);
+ const servers = content.mcpServers ?? {};
+ const managedEntry = servers['parallel-code'];
+ if (mcpEntryFingerprint(managedEntry) !== state.writtenParallelCodeFingerprint)
+ return { status: 'failed' };
+
+ 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);
+ task.autoDiscoveredMcpConfig = undefined;
+ this.syncAutoDiscoveredMcpConfig(task);
+ 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 { 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 { 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);
+ if (tokens.length === 0) {
+ throw new Error(
+ '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', 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))) {
+ 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.',
+ );
+ }
+ }
+
+ 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),
+ });
+ }
+ }
+
/** Best-effort removal of a task's per-sub-task MCP config file. */
private unlinkMcpConfigFile(path: string | undefined): void {
if (!path) return;
@@ -1529,6 +1809,7 @@ export class Coordinator {
}
private clearTaskMcpConfig(task: CoordinatedTask): void {
+ this.restoreTaskAutoDiscoveredMcpConfig(task);
this.unlinkMcpConfigFile(task.mcpConfigPath);
task.mcpConfigPath = undefined;
}
@@ -1604,9 +1885,29 @@ export class Coordinator {
task.verification = input.verification;
task.landingSummary = input.summary;
+ const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
+ 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);
+ }
+ 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) {
+ if (shouldRefreshMcpConfig) this.refreshTaskMcpConfigAfterLandingFailure(task);
const reason = err instanceof Error ? err.message : String(err);
this.escalateLanding(task, 'landing_escalated', reason);
throw err;
@@ -1616,6 +1917,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')
@@ -1697,42 +1999,66 @@ export class Coordinator {
const task = this.tasks.get(taskId);
if (!task) throw new Error(`Task not found: ${taskId}`);
this.assertTaskCanBeMerged(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);
+ const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task);
+ 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.',
+ );
+ }
+ if (restoreMcpConfig.managedEntry !== undefined) {
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
+ 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,
+ // 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
+ }
+ }
- 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 {
@@ -1895,12 +2221,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`);
@@ -1917,14 +2247,24 @@ export class Coordinator {
const existingTask = this.tasks.get(opts.id);
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 = {
@@ -1950,6 +2290,11 @@ export class Coordinator {
landingSummary: opts.landingSummary,
landedMetadata: opts.landedMetadata,
preambleFileExistedBefore: opts.preambleFileExistedBefore,
+ agentCommand: opts.agentCommand,
+ autoDiscoveredMcpConfig: validateAutoDiscoveredMcpConfigState(
+ opts.autoDiscoveredMcpConfig,
+ opts.worktreePath,
+ ),
};
this.tasks.set(task.id, task);
if (opts.landedMetadata) {
@@ -1991,12 +2336,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;
}
@@ -2022,7 +2371,9 @@ export class Coordinator {
if (mcpConfigPath) {
writeSubTaskMcpConfigSync(mcpConfigPath, mcpConfig);
}
- return buildMcpLaunchArgs(agentCommand ?? 'claude', mcpConfigPath, mcpConfig);
+ task.agentCommand = agentCommand ?? task.agentCommand ?? 'claude';
+ this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig);
+ return buildMcpLaunchArgs(task.agentCommand, mcpConfigPath, mcpConfig);
}
isRegisteredCoordinator(coordinatorTaskId: string): boolean {
diff --git a/electron/mcp/dockerfile.test.ts b/electron/mcp/dockerfile.test.ts
new file mode 100644
index 00000000..2f20a11e
--- /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('# Keep Kimi below 0.33');
+ expect(dockerfile).toContain('@moonshot-ai/kimi-code@0.32.0');
+ });
+});
diff --git a/electron/mcp/preamble.test.ts b/electron/mcp/preamble.test.ts
index ce6e2c7c..3fde21a3 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',
+ 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')) {
diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts
index 7ddf624a..9f4216c6 100644
--- a/electron/mcp/types.ts
+++ b/electron/mcp/types.ts
@@ -1,5 +1,10 @@
// Shared types for the MCP coordinating-agent system.
+export interface AutoDiscoveredMcpConfigState {
+ path: string;
+ writtenParallelCodeFingerprint: string;
+}
+
export interface CoordinatedTask {
id: string;
name: string;
@@ -16,6 +21,8 @@ export interface CoordinatedTask {
initialPrompt?: string;
automationWriteInFlight?: boolean;
mcpConfigPath?: string; // path to per-task tmp config, deleted on cleanup
+ 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)
signalDoneAt?: Date; // set when sub-task explicitly calls signal_done
diff --git a/src/App.tsx b/src/App.tsx
index 67846915..99e0b536 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -67,7 +67,7 @@ import {
} from './store/store';
import { isGitHubUrl } from './lib/github-url';
import { HoldToQuit } from './components/HoldToQuit';
-import type { PersistedWindowState } from './store/types';
+import type { PersistedWindowState, Task } from './store/types';
import {
initShortcuts,
registerFromRegistry,
@@ -455,7 +455,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,
@@ -474,6 +477,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/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/autosave.ts b/src/store/autosave.ts
index 2476b1f9..f977284d 100644
--- a/src/store/autosave.ts
+++ b/src/store/autosave.ts
@@ -68,6 +68,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..4d7570f3 100644
--- a/src/store/persistence.test.ts
+++ b/src/store/persistence.test.ts
@@ -243,6 +243,65 @@ describe('landing state persistence', () => {
});
});
+describe('Kimi auto-discovered MCP config persistence', () => {
+ const autoDiscoveredMcpConfig = {
+ path: '/repo/.worktrees/task-1/.kimi-code/mcp.json',
+ 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 47e47e0b..e28cdfb0 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,
@@ -699,6 +700,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,
@@ -805,6 +807,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 cc7d7647..f634e3a1 100644
--- a/src/store/tasks.test.ts
+++ b/src/store/tasks.test.ts
@@ -735,6 +735,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: [],
@@ -1343,6 +1363,20 @@ 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/.kimi-code/mcp.json',
+ 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 498ac827..9644a23e 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 {
@@ -1093,6 +1099,7 @@ interface MCPTaskCreatedEvent {
coordinatorTaskId: string;
prompt?: string;
mcpConfigPath?: string;
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState;
preambleFileExistedBefore?: boolean;
agentCommand?: string;
agentArgs?: string[];
@@ -1127,6 +1134,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,
@@ -1300,6 +1308,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;
};
@@ -1338,11 +1347,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) => {
@@ -1387,19 +1403,30 @@ 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))
);
}
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;
@@ -1408,6 +1435,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;
}
@@ -1472,7 +1508,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,
@@ -1491,6 +1530,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 4f6f375e..bc253d11 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -9,6 +9,11 @@ export type KeybindingOverride = Partial>
export type GitIsolationMode = 'worktree' | 'direct' | 'none';
+export interface AutoDiscoveredMcpConfigState {
+ path: string;
+ writtenParallelCodeFingerprint: string;
+}
+
export interface StagedNotification {
batchId: string;
notificationIds: string[];
@@ -144,6 +149,7 @@ export interface Task {
controlledBy?: 'coordinator' | 'human';
automationWriteInFlight?: boolean;
mcpConfigPath?: string;
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState;
mcpLaunchArgs?: string[];
preambleFileExistedBefore?: boolean;
signalDoneReceived?: boolean;
@@ -204,6 +210,7 @@ export interface PersistedTask {
coordinatedBy?: string;
controlledBy?: 'coordinator' | 'human';
mcpConfigPath?: string;
+ autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState;
preambleFileExistedBefore?: boolean;
signalDoneReceived?: boolean;
signalDoneAt?: string;