Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fae6f11
feat(agents): add Kimi Code CLI support
LarryHu0217 Jul 31, 2026
e79bf5e
fix(mcp): wire Kimi coordinator children
LarryHu0217 Aug 1, 2026
e6236f3
fix(mcp): preserve Kimi launch args on hydration
LarryHu0217 Aug 2, 2026
d4ae1a6
fix(mcp): preserve Kimi config restoration state
LarryHu0217 Aug 4, 2026
0cb1285
fix(mcp): fail closed on Kimi config restoration
LarryHu0217 Aug 5, 2026
3f91f45
fix(mcp): keep Kimi tokens out of persisted history
LarryHu0217 Aug 6, 2026
1508018
fix(mcp): keep Kimi child credentials off tracked paths
LarryHu0217 Aug 6, 2026
7fc70ae
fix(mcp): write Kimi child preamble to AGENTS
LarryHu0217 Aug 7, 2026
08efdb4
Merge upstream main into codex/kimi-cli-109
LarryHu0217 Aug 13, 2026
061ec67
fix(mcp): harden Kimi config refresh
LarryHu0217 Aug 14, 2026
c5cd4b2
fix(mcp): recover missing Kimi managed entries
LarryHu0217 Aug 21, 2026
8293bc4
fix(mcp): fail closed when managed entry is lost
LarryHu0217 Aug 29, 2026
9645906
fix(mcp): harden Kimi credential lifecycle
LarryHu0217 Aug 30, 2026
3b8663a
Merge upstream main and guard Kimi verification failures
LarryHu0217 Sep 3, 2026
f7f3cae
refactor(mcp): unify missing Kimi config recovery
LarryHu0217 Sep 8, 2026
cbbb5dd
Merge upstream main into codex/kimi-cli-109
johannesjo Sep 8, 2026
5b0a24b
fix(mcp): anchor Kimi git-exclude patterns to the worktree root
Sep 8, 2026
6c19f68
fix(mcp): re-arm the Kimi child MCP config when restoration fails closed
Sep 8, 2026
a986a6f
fix(agents): enforce Docker-only Kimi launches
LarryHu0217 Sep 10, 2026
ed0cf23
fix(mcp): cover atomic config files in Git safeguards
LarryHu0217 Sep 11, 2026
c541834
fix(mcp): preserve existing Kimi task state on hydration failure
LarryHu0217 Sep 12, 2026
a224525
fix(mcp): batch Kimi credential path exclusions
LarryHu0217 Sep 12, 2026
e26077c
Merge upstream main and preserve Kimi landing validation
LarryHu0217 Sep 12, 2026
385088a
refactor(mcp): share auto-discovered config state across IPC
LarryHu0217 Sep 14, 2026
bd0c46d
fix(mcp): explain safe recovery from Kimi config conflicts
LarryHu0217 Sep 14, 2026
64c0765
fix(mcp): identify Kimi preambles by executable basename
LarryHu0217 Sep 14, 2026
7ad0b38
fix(mcp): reject Kimi fallback shadowed by tracked config
LarryHu0217 Sep 25, 2026
513b7ed
fix(mcp): reconcile Kimi integration with current main
LarryHu0217 Sep 26, 2026
cf32289
fix(agents): reconcile Kimi selector with current main
LarryHu0217 Oct 6, 2026
76c3387
fix(agents): preserve Kimi support across main sync
LarryHu0217 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,18 +1,65 @@
#!/bin/sh
set -e

npx lint-staged
npm run check

# Verify package-lock.json is committed and in sync with package.json
if git diff --cached --name-only | grep -q "package\.json$"; then
if ! git diff --cached --name-only | grep -q "package-lock\.json$"; then
echo "Error: package.json changed without updating package-lock.json"
echo "Run 'npm install' to update the lockfile"
exit 1
if ! git diff --cached --name-only -- package-lock.json | grep -qx "package-lock\.json"; then
# Development commands are not recorded in the lockfile. Compare the index
# with HEAD, retaining all other fields and installation lifecycle scripts.
if ! node --input-type=module <<'NODE'
import { execFileSync } from 'node:child_process';
import process from 'node:process';
import { isDeepStrictEqual } from 'node:util';

function readManifest(revision) {
const manifest = JSON.parse(execFileSync('git', ['show', revision], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}));
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error('Invalid manifest');
}
const scripts = manifest.scripts ?? {};
if (typeof scripts !== 'object' || Array.isArray(scripts)) {
throw new Error('Invalid scripts');
}
const installScripts = [
'preinstall', 'install', 'postinstall',
'preprepare', 'prepare', 'postprepare', 'prepublish',
].map((name) => scripts[name]);
delete manifest.scripts;
return { manifest, installScripts };
}

try {
const paths = execFileSync('git', ['diff', '--cached', '--name-only', '-z'], { encoding: 'utf8' })
.split('\0').filter((path) => /(^|\/)package\.json$/.test(path));
process.exitCode = paths.every((path) => isDeepStrictEqual(
readManifest('HEAD:' + path), readManifest(':' + path),
)) ? 0 : 1;
} catch {
process.exitCode = 1;
}
NODE
then
echo "Error: package.json changed without updating package-lock.json"
echo "Run 'npm install' to update the lockfile"
exit 1
fi
fi
fi

# The lockfile must remain in the commit, not just exist in the working tree.
if ! git ls-files --error-unmatch -- package-lock.json >/dev/null 2>&1; then
echo "Error: package-lock.json must be tracked"
exit 1
fi

# Ensure package-lock.json is not gitignored (supply chain: lockfile must be tracked)
if git check-ignore -q package-lock.json 2>/dev/null; then
if git check-ignore --no-index -q package-lock.json 2>/dev/null; then
echo "Error: package-lock.json must not be gitignored — it provides integrity hashes"
exit 1
fi
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Electron desktop app for running coding agents in isolated Git worktrees. Deskto
- CI tests that Semgrep rules work on fixtures; it does not scan the repository with Semgrep. `npm run lint:security` and `npm run lint:secrets` run separate scans and require Semgrep and Gitleaks respectively.
- happy-dom cannot verify native Electron views. For browser-preview changes, follow the native smoke checks in `docs/browser-preview.md`.

When committing, use conventional commit messages, such as `fix(terminal): restore focus`. Git hooks enforce the format and run `lint-staged`, `npm run check`, and a lockfile check on commit; pushing runs `check` and `npm test`. Changes to `package.json` must include the corresponding `package-lock.json` update.
When committing, use conventional commit messages, such as `fix(terminal): restore focus`. Git hooks enforce the format and run `lint-staged`, `npm run check`, and a lockfile check on commit; pushing runs `check` and `npm test`. Changes to dependency or package metadata and installation lifecycle scripts in `package.json` must include the corresponding `package-lock.json` update. Development-script-only changes do not require a no-op lockfile update. The lockfile must remain tracked and must not be ignored.

## Architecture and conventions

Expand Down
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Unlike the AI CLIs above, the following network activity is initiated by Paralle
- **Over Tailscale.** Traffic is carried by your tailnet — typically a direct WireGuard connection between your devices, but Tailscale's coordination service and (when direct connection is not possible) DERP relays may be involved per [Tailscale's network architecture](https://tailscale.com/kb/1257/connection-types). How Tailscale handles that traffic is governed by Tailscale's own policies, not this one.
- **Sub-task coordinator (MCP)** — when sub-tasks run under a coordinator agent, Parallel Code starts a local token-protected HTTP/WebSocket server so sub-task agents can call back into the app (e.g. to signal completion). No traffic from this feature passes through infrastructure operated by the Parallel Code project.
- **Bind address.** When MCP starts its own listener, it binds to `127.0.0.1` except on macOS Docker setups, where it binds to `0.0.0.0` so containers can reach it via `host.docker.internal` — this also makes the port reachable from other hosts on your LAN, though access still requires the token. If a Remote Access server is already running when a coordinator starts, the coordinator reuses that listener; because Remote Access binds to `0.0.0.0`, MCP routes inherit that LAN reach on any platform (including Linux), though access still requires the MCP token.
- **Where the token can land.** Token-bearing MCP data is written or passed in several places: a worktree `.mcp.json` when a worktree path is available, or a project-root `.mcp.json` otherwise, so the coordinator agent can auto-discover the server (Parallel Code also adds `.mcp.json` to your `.git/info/exclude` so it is not committed); a non-Docker coordinator config in your OS temp directory named `parallel-code-mcp-<coordinatorTaskId>.json`; per-sub-task configs in your OS temp directory for host-mode sub-tasks (`parallel-code-subtask-<taskId>.json`) or under the coordinator's `.parallel-code/` directory for Docker sub-tasks (`subtask-<taskId>.json`); and short-lived `.parallel-code-atomic-<uuid>.tmp` files written next to these configs during atomic-rename steps. These files are written with `0600` permissions where the platform supports it.
- **Where the token can land.** Token-bearing MCP data is written or passed in several places: a worktree `.mcp.json` when a worktree path is available, or a project-root `.mcp.json` otherwise, so the coordinator agent can auto-discover the server (Parallel Code also adds `.mcp.json` to your `.git/info/exclude` so it is not committed); an auto-discovered `.kimi-code/mcp.json` (or fallback `.mcp.json`) inside each Kimi sub-task worktree, with both that config and its adjacent `.parallel-code-atomic-*.tmp` files added to `.git/info/exclude` before credentials are written; a non-Docker coordinator config in your OS temp directory named `parallel-code-mcp-<coordinatorTaskId>.json`; per-sub-task configs in your OS temp directory for host-mode sub-tasks (`parallel-code-subtask-<taskId>.json`) or under the coordinator's `.parallel-code/` directory for Docker sub-tasks (`subtask-<taskId>.json`); and short-lived `.parallel-code-atomic-<uuid>.tmp` files written next to these configs during atomic-rename steps. These files are written with `0600` permissions where the platform supports it.
- **Codex token in the command line.** For Codex specifically, the MCP token is passed as a literal command-line argument (`--config mcp_servers.parallel-code={... env = { PARALLEL_CODE_MCP_TOKEN = "..." }}`). Process command lines are visible to other processes — via `/proc/<pid>/cmdline` on Linux or `ps` on macOS — so any local process that runs concurrently with a Codex sub-task can read that token and call back into the coordinator under its authority until the coordinator exits. Other agents receive the token through a token-protected file or env var instead.
- **Docker task isolation** — when you enable Docker mode for a task, or when you opt coordinator sub-tasks into Docker-isolated mode, the agent launches in a container via `docker run --network host`. **Docker mode is not a security boundary.** It isolates the filesystem against the worktree, but does not isolate the network or credentials from the agent.
- **Network.** `--network host` means the container shares the host's network namespace; its outbound reachability is the same as your host's, including loopback services and any LAN address your host can reach.
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</p>

<p align="center">
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 Docker-pinned Kimi Code · Every change isolated in its own git worktree · Free, open source, no extra platform fee
</p>

<p align="center">
Expand Down Expand Up @@ -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), Docker-pinned [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.
Expand Down Expand Up @@ -121,10 +121,26 @@ 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), [Antigravity CLI](https://antigravity.google/), or [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli). Kimi Code is currently supported through the bundled Docker image instead of a native installation.

3. **Open Parallel Code**, point it at a git repo, and start dispatching tasks.

<details>
<summary><strong>Kimi Code: use Docker mode</strong></summary>

Parallel Code's Kimi integration writes an auto-discovered project MCP config into each fresh
task worktree. Kimi Code 0.33 added a workspace-trust prompt, and 0.36 defaults to declining
project MCP launch targets in that prompt. A current native Kimi installation can therefore
pause on every new worktree instead of starting the task unattended.

Use Docker-isolated Kimi tasks for now. The bundled image pins Kimi Code 0.32, before the
workspace-trust gate. Native Kimi support is not currently claimed.
Kimi is hidden from native task agent pickers, and native launches (including saved/custom
`kimi` definitions) are rejected with an actionable Docker-mode error. Existing running
terminals can still reattach after a renderer reload.

</details>

<details>
<summary><strong>Antigravity CLI: run natively, not in Docker isolation</strong></summary>

Expand Down
3 changes: 2 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions electron/ipc/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ const DEFAULT_AGENTS: AgentDef[] = [
skip_permissions_args: getSkipPermissionsArgs('gemini'),
description: "Google's Gemini CLI agent",
},
{
id: 'kimi',
name: 'Kimi Code CLI',
command: 'kimi',
args: [],
resume_args: ['--continue'],
skip_permissions_args: getSkipPermissionsArgs('kimi'),
description: "Moonshot AI's Kimi Code CLI agent",
},
{
id: 'opencode',
name: 'OpenCode',
Expand Down
126 changes: 126 additions & 0 deletions electron/ipc/git-exclude-batch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as childProcess from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appendGitInfoExcludeBlocks } from './git-exclude.js';

vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
});

describe('batched Git exclusions', () => {
let dir: string;
let excludePath: string;
const patterns = ['/.kimi-code/mcp.json', '/.kimi-code/.parallel-code-atomic-*.tmp'];
const blocks = patterns.map((marker) => ({ marker, block: `${marker}\n` }));

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-exclude-batch-'));
childProcess.execFileSync('git', ['init', '-q', dir]);
excludePath = path.join(dir, '.git/info/exclude');
vi.mocked(childProcess.execFileSync).mockClear();
});

afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(dir, { recursive: true, force: true });
});

it.each(['', '/.kimi-code/mcp.json\n', '/.kimi-code/.parallel-code-atomic-*.tmp\n'])(
'resolves and reads once and appends only missing patterns after %j',
(existing) => {
fs.writeFileSync(excludePath, existing);
const read = vi.spyOn(fs, 'readFileSync');
const append = vi.spyOn(fs, 'appendFileSync');
expect(appendGitInfoExcludeBlocks(dir, blocks)).toBe('appended');
expect(childProcess.execFileSync).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledTimes(1);
expect(append).toHaveBeenCalledTimes(1);
const written = append.mock.calls[0][1];
for (const pattern of patterns) {
expect(String(written).includes(pattern)).toBe(!existing.includes(pattern));
}
for (const file of ['.kimi-code/mcp.json', '.kimi-code/.parallel-code-atomic-test.tmp']) {
expect(() =>
childProcess.execFileSync('git', ['check-ignore', '-q', file], { cwd: dir }),
).not.toThrow();
}
append.mockClear();
expect(appendGitInfoExcludeBlocks(dir, blocks)).toBe('present');
expect(append).not.toHaveBeenCalled();
},
);

it('recognizes Git-normalized existing lines without rewriting them', () => {
const existing = `${patterns[0]} \r\n${patterns[1]}\r\n# user rule\n/user-data\n`;
fs.writeFileSync(excludePath, existing);
const append = vi.spyOn(fs, 'appendFileSync');
expect(appendGitInfoExcludeBlocks(dir, blocks)).toBe('present');
expect(append).not.toHaveBeenCalled();
expect(fs.readFileSync(excludePath, 'utf8')).toBe(existing);
});

it('creates a missing exclude file with both entries', () => {
fs.unlinkSync(excludePath);
expect(appendGitInfoExcludeBlocks(dir, blocks)).toBe('appended');
expect(fs.readFileSync(excludePath, 'utf8')).toBe(patterns.join('\n') + '\n');
});

it.each(['', '.kimi-code/'])('keeps %j exclusions root-anchored in real Git', (prefix) => {
const files = [`${prefix}mcp.json`, `${prefix}.parallel-code-atomic-test.tmp`];
if (!prefix) files[0] = '.mcp.json';
const rules = [`/${files[0]}`, `/${prefix}.parallel-code-atomic-*.tmp`];
expect(
appendGitInfoExcludeBlocks(
dir,
rules.map((marker) => ({ marker, block: marker })),
),
).toBe('appended');
for (const file of files) {
expect(childProcess.spawnSync('git', ['check-ignore', '-q', file], { cwd: dir }).status).toBe(
0,
);
expect(
childProcess.spawnSync('git', ['check-ignore', '-q', `nested/${file}`], { cwd: dir })
.status,
).toBe(1);
}
});

it('reports only the missing pattern when its append fails', () => {
fs.writeFileSync(excludePath, blocks[0].block);
const error = new Error('append denied');
vi.spyOn(fs, 'appendFileSync').mockImplementationOnce(() => {
throw error;
});
const onError = vi.fn();
expect(appendGitInfoExcludeBlocks(dir, blocks, onError)).toBe('failed');
expect(onError).toHaveBeenCalledWith(error, [patterns[1]]);
expect(fs.readFileSync(excludePath, 'utf8')).toBe(blocks[0].block);
});

it('fails without writing when the exclude file cannot be read', () => {
const error = Object.assign(new Error('read denied'), { code: 'EACCES' });
vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => {
throw error;
});
const append = vi.spyOn(fs, 'appendFileSync');
const onError = vi.fn();
expect(appendGitInfoExcludeBlocks(dir, blocks, onError)).toBe('failed');
expect(onError).toHaveBeenCalledWith(error, patterns);
expect(append).not.toHaveBeenCalled();
});

it('does not read or write if Git cannot resolve the common directory', () => {
vi.mocked(childProcess.execFileSync).mockImplementationOnce(() => {
throw new Error('git timeout');
});
const read = vi.spyOn(fs, 'readFileSync');
const append = vi.spyOn(fs, 'appendFileSync');
expect(appendGitInfoExcludeBlocks(dir, blocks)).toBe('missing');
expect(read).not.toHaveBeenCalled();
expect(append).not.toHaveBeenCalled();
});
});
38 changes: 38 additions & 0 deletions electron/ipc/git-exclude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,41 @@ export function appendGitInfoExcludeBlock(
if (!excludePath) return 'missing';
return appendGitInfoExcludeBlockAtPath(excludePath, marker, block, onError);
}

/** Resolve and read once, then append only the missing blocks in a single write. */
export function appendGitInfoExcludeBlocks(
worktreePath: string,
blocks: ReadonlyArray<{ marker: string; block: string }>,
onError?: (err: unknown, markers: string[]) => void,
): AppendGitInfoExcludeResult {
if (blocks.length === 0) return 'present';
const excludePath = resolveGitInfoExcludePath(worktreePath);
if (!excludePath) return 'missing';
let existing = '';
try {
existing = fs.readFileSync(excludePath, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
onError?.(
err,
blocks.map(({ marker }) => marker),
);
return 'failed';
}
}
const known = new Set(existing.split('\n').map(normalizeExcludeLine));
const missing = blocks.filter(({ marker }) => !known.has(marker));
if (missing.length === 0) return 'present';
return appendGitInfoExcludeBlockAtPath(
excludePath,
missing[0].marker,
missing.map(({ block }) => (block.endsWith('\n') ? block : `${block}\n`)).join(''),
(err) =>
onError?.(
err,
missing.map(({ marker }) => marker),
),
existing,
true,
);
}
Loading
Loading