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;