Skip to content

Commit 5af7cf1

Browse files
committed
feat(coordinator): add coordinator UI and launch fixes
1 parent 326c9f5 commit 5af7cf1

83 files changed

Lines changed: 18762 additions & 1468 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ jobs:
1010
quality:
1111
runs-on: ubuntu-latest
1212
steps:
13-
- uses: actions/checkout@v4
13+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
1414

15-
- uses: actions/setup-node@v4
15+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
1616
with:
17-
node-version: 'lts/*'
17+
node-version: '22'
1818
cache: npm
1919

2020
- run: npm ci

.github/workflows/release.yml

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ jobs:
1414
outputs:
1515
release_id: ${{ steps.create.outputs.id }}
1616
steps:
17-
- uses: actions/checkout@v4
17+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
1818

1919
- name: Create draft release
2020
id: create
21-
uses: softprops/action-gh-release@v2
21+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
2222
with:
2323
draft: true
2424
generate_release_notes: true
@@ -27,18 +27,18 @@ jobs:
2727
needs: create-release
2828
runs-on: ubuntu-22.04
2929
steps:
30-
- uses: actions/checkout@v4
30+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
3131

32-
- uses: actions/setup-node@v4
32+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
3333
with:
34-
node-version: 'lts/*'
34+
node-version: '22'
3535
cache: npm
3636

3737
- run: npm ci
3838
- run: npm run build -- --publish never
3939

4040
- name: Upload artifacts
41-
uses: softprops/action-gh-release@v2
41+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
4242
with:
4343
tag_name: ${{ github.ref_name }}
4444
files: |
@@ -50,11 +50,11 @@ jobs:
5050
needs: create-release
5151
runs-on: macos-latest
5252
steps:
53-
- uses: actions/checkout@v4
53+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
5454

55-
- uses: actions/setup-node@v4
55+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
5656
with:
57-
node-version: 'lts/*'
57+
node-version: '22'
5858
cache: npm
5959

6060
- name: Import code signing certificate
@@ -98,7 +98,7 @@ jobs:
9898
run: npm run build -- --universal --publish never
9999

100100
- name: Upload artifacts
101-
uses: softprops/action-gh-release@v2
101+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
102102
with:
103103
tag_name: ${{ github.ref_name }}
104104
files: release/*.dmg

.husky/commit-msg

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/sh
2+
commit_msg=$(cat "$1")
3+
4+
# Allow merge commits, revert commits, and fixup/squash commits
5+
if echo "$commit_msg" | grep -qE "^(Merge|Revert|fixup!|squash!)"; then
6+
exit 0
7+
fi
8+
9+
# Enforce conventional commit format: type(scope): message
10+
if ! echo "$commit_msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}"; then
11+
echo ""
12+
echo "Invalid commit message format."
13+
echo "Expected: type(scope): description"
14+
echo "Examples:"
15+
echo " feat(coordinator): add MCP status indicator"
16+
echo " fix(terminal): restore PTY preservation on reload"
17+
echo " chore: update dependencies"
18+
echo ""
19+
echo "Valid types: feat fix docs style refactor perf test build ci chore revert"
20+
exit 1
21+
fi

.husky/pre-commit

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
11
#!/bin/sh
22
npx lint-staged
33
npm run check
4+
5+
# Verify package-lock.json is committed and in sync with package.json
6+
if git diff --cached --name-only | grep -q "package\.json$"; then
7+
if ! git diff --cached --name-only | grep -q "package-lock\.json$"; then
8+
echo "Error: package.json changed without updating package-lock.json"
9+
echo "Run 'npm install' to update the lockfile"
10+
exit 1
11+
fi
12+
fi
13+
14+
# Ensure package-lock.json is not gitignored (supply chain: lockfile must be tracked)
15+
if git check-ignore -q package-lock.json 2>/dev/null; then
16+
echo "Error: package-lock.json must not be gitignored — it provides integrity hashes"
17+
exit 1
18+
fi

.husky/pre-push

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
11
#!/bin/sh
22
npm run check
3+
4+
echo "Running tests before push..."
5+
npm test
6+
if [ $? -ne 0 ]; then
7+
echo ""
8+
echo "Tests failed. Fix failing tests before pushing."
9+
echo "Run 'npm test' to see details."
10+
exit 1
11+
fi

electron/ipc/agents.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execFile } from 'child_process';
22
import { promisify } from 'util';
3+
import path from 'path';
34

45
const execFileAsync = promisify(execFile);
56

@@ -81,6 +82,12 @@ let cachedAgents: AgentDef[] | null = null;
8182
let cacheTime = 0;
8283
const AGENT_CACHE_TTL = 30_000;
8384

85+
export function getSkipPermissionsArgs(command: string): string[] {
86+
const base = path.basename(command);
87+
const agent = DEFAULT_AGENTS.find((a) => a.command === base || a.command === command);
88+
return agent ? agent.skip_permissions_args : [];
89+
}
90+
8491
export async function listAgents(): Promise<AgentDef[]> {
8592
const now = Date.now();
8693
if (cachedAgents && now - cacheTime < AGENT_CACHE_TTL) {

electron/ipc/channels.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,31 @@ export enum IPC {
137137

138138
// Logging
139139
LogFromRenderer = 'log_from_renderer',
140+
141+
// MCP / Coordinating agent
142+
SetCoordinatorModeEnabled = 'set_coordinator_mode_enabled',
143+
StartMCPServer = 'start_mcp_server',
144+
StopMCPServer = 'stop_mcp_server',
145+
GetMCPStatus = 'get_mcp_status',
146+
GetMCPLogs = 'get_mcp_logs',
147+
MCP_TaskCreated = 'mcp_task_created',
148+
MCP_TaskClosed = 'mcp_task_closed',
149+
MCP_TaskStateSync = 'mcp_task_state_sync',
150+
MCP_ControlChanged = 'mcp_control_changed',
151+
// Coordinator notifications (main → renderer)
152+
MCP_CoordinatorNotificationStaged = 'mcp_coordinator_notification_staged',
153+
MCP_CoordinatorNotificationCleared = 'mcp_coordinator_notification_cleared',
154+
MCP_CoordinatorOrphanedNotification = 'mcp_coordinator_orphaned_notification',
155+
// Coordinator lifecycle (renderer → main)
156+
MCP_CoordinatorRegistered = 'mcp_coordinator_registered',
157+
MCP_CoordinatorDeregistered = 'mcp_coordinator_deregistered',
158+
MCP_CoordinatorNotificationAck = 'mcp_coordinator_notification_ack',
159+
MCP_CoordinatorNotificationDropAck = 'mcp_coordinator_notification_drop_ack',
160+
MCP_CoordinatedTaskPromptDelivered = 'mcp_coordinated_task_prompt_delivered',
161+
MCP_CoordinatorRestageAfterUserSend = 'mcp_coordinator_restage_after_user_send',
162+
MCP_HydrateCoordinatedTask = 'mcp_hydrate_coordinated_task',
163+
MCP_TaskHydrated = 'mcp_task_hydrated',
164+
MCP_StaleUrlWarning = 'mcp_stale_url_warning',
165+
MCP_CoordinatedTaskClosed = 'mcp_coordinated_task_closed',
166+
MCP_TaskCleanupFailed = 'mcp_task_cleanup_failed',
140167
}

electron/ipc/docker-config.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Layer 1 — Docker coordinator config (pure, no Docker required)
3+
*
4+
* Fast unit tests for the pure functions that generate MCP config for Docker coordinators.
5+
* No Docker, no network, no filesystem writes.
6+
*/
7+
8+
import { describe, expect, it } from 'vitest';
9+
import {
10+
buildCoordinatorMCPConfig,
11+
getDockerMcpServerDestPath,
12+
selectMcpJsonDir,
13+
} from './register.js';
14+
import { getMCPRemoteServerUrl } from '../mcp/config.js';
15+
16+
// ── MCP server URL ─────────────────────────────────────────────────────────────
17+
18+
describe('getMCPRemoteServerUrl — host resolution', () => {
19+
it('uses host.docker.internal on macOS Docker Desktop', () => {
20+
expect(getMCPRemoteServerUrl(3001, 'my-container', 'darwin')).toBe(
21+
'http://host.docker.internal:3001',
22+
);
23+
});
24+
25+
it('uses 127.0.0.1 on Linux (--network host makes localhost IS the host)', () => {
26+
// --add-host=host.docker.internal:host-gateway is incompatible with --network host on Linux.
27+
// With --network host the container shares the host network stack, so 127.0.0.1 IS the host.
28+
expect(getMCPRemoteServerUrl(3001, 'my-container', 'linux')).toBe('http://127.0.0.1:3001');
29+
});
30+
31+
it('uses 127.0.0.1 when no container name (non-Docker)', () => {
32+
expect(getMCPRemoteServerUrl(3001, undefined)).toBe('http://127.0.0.1:3001');
33+
});
34+
35+
it('uses 127.0.0.1 when container name is empty string', () => {
36+
expect(getMCPRemoteServerUrl(3001, '')).toBe('http://127.0.0.1:3001');
37+
});
38+
});
39+
40+
// ── .mcp.json placement ────────────────────────────────────────────────────────
41+
42+
describe('selectMcpJsonDir — .mcp.json placement', () => {
43+
it('places .mcp.json in worktreePath when provided', () => {
44+
expect(selectMcpJsonDir('/worktrees/coord-abc', '/project')).toBe('/worktrees/coord-abc');
45+
});
46+
47+
it('falls back to projectRoot when worktreePath is undefined', () => {
48+
expect(selectMcpJsonDir(undefined, '/project')).toBe('/project');
49+
});
50+
51+
it('worktreePath wins over projectRoot (Docker: container only mounts worktree)', () => {
52+
const worktreePath = '/Users/alice/repo/.worktrees/task/coord-abc123';
53+
const projectRoot = '/Users/alice/repo';
54+
const dir = selectMcpJsonDir(worktreePath, projectRoot);
55+
// .mcp.json must be inside the volume-mounted worktree, not the projectRoot (not mounted)
56+
expect(dir).toBe(worktreePath);
57+
expect(dir).not.toBe(projectRoot);
58+
});
59+
});
60+
61+
// ── copied mcp-server.cjs path ─────────────────────────────────────────────────
62+
63+
describe('getDockerMcpServerDestPath — copied mcp-server.cjs location', () => {
64+
it('places mcp-server.cjs in worktree .parallel-code dir', () => {
65+
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
66+
expect(dest).toBe('/worktrees/coord/.parallel-code/mcp-server.cjs');
67+
});
68+
69+
it('falls back to projectRoot when worktreePath is undefined', () => {
70+
const dest = getDockerMcpServerDestPath(undefined, '/project');
71+
expect(dest).toBe('/project/.parallel-code/mcp-server.cjs');
72+
});
73+
74+
it('dest is under the mounted worktree, not the unmounted projectRoot', () => {
75+
const worktreePath = '/home/user/repo/.worktrees/task/coord-abc123';
76+
const projectRoot = '/home/user/repo';
77+
const dest = getDockerMcpServerDestPath(worktreePath, projectRoot);
78+
// The container mounts worktreePath (not projectRoot), so the script must live there
79+
expect(dest.startsWith(worktreePath)).toBe(true);
80+
expect(dest.startsWith(projectRoot + '/.parallel-code')).toBe(false);
81+
});
82+
83+
it('filename is always mcp-server.cjs', () => {
84+
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
85+
expect(dest.endsWith('/mcp-server.cjs')).toBe(true);
86+
});
87+
});
88+
89+
// ── .mcp.json config content ───────────────────────────────────────────────────
90+
91+
describe('buildCoordinatorMCPConfig — config content', () => {
92+
const baseOpts = {
93+
mcpServerPath: '/worktrees/coord/.parallel-code/mcp-server.cjs',
94+
serverUrl: 'http://host.docker.internal:3001',
95+
token: 'test-token-abc',
96+
coordinatorTaskId: 'coord-task-1',
97+
};
98+
99+
it('has type:stdio and command:node', () => {
100+
const cfg = buildCoordinatorMCPConfig(baseOpts);
101+
const server = cfg.mcpServers['parallel-code'];
102+
expect(server.type).toBe('stdio');
103+
expect(server.command).toBe('node');
104+
});
105+
106+
it('args[0] is the mcp-server.cjs path (the copied worktree path, not host path)', () => {
107+
const cfg = buildCoordinatorMCPConfig(baseOpts);
108+
expect(cfg.mcpServers['parallel-code'].args[0]).toBe(baseOpts.mcpServerPath);
109+
});
110+
111+
it('args contain --url pointing to host.docker.internal', () => {
112+
const cfg = buildCoordinatorMCPConfig(baseOpts);
113+
const args = cfg.mcpServers['parallel-code'].args;
114+
const urlIdx = args.indexOf('--url');
115+
expect(urlIdx).toBeGreaterThan(0);
116+
expect(args[urlIdx + 1]).toBe('http://host.docker.internal:3001');
117+
});
118+
119+
it('token is passed via env var, not args', () => {
120+
const cfg = buildCoordinatorMCPConfig(baseOpts);
121+
const args = cfg.mcpServers['parallel-code'].args;
122+
expect(args).not.toContain('--token');
123+
expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe(baseOpts.token);
124+
});
125+
126+
it('args contain --coordinator-id', () => {
127+
const cfg = buildCoordinatorMCPConfig(baseOpts);
128+
const args = cfg.mcpServers['parallel-code'].args;
129+
const coordIdx = args.indexOf('--coordinator-id');
130+
expect(coordIdx).toBeGreaterThan(0);
131+
expect(args[coordIdx + 1]).toBe(baseOpts.coordinatorTaskId);
132+
});
133+
134+
it('omits --skip-permissions by default', () => {
135+
const cfg = buildCoordinatorMCPConfig(baseOpts);
136+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
137+
});
138+
139+
it('adds --skip-permissions when both flags are true', () => {
140+
const cfg = buildCoordinatorMCPConfig({
141+
...baseOpts,
142+
skipPermissions: true,
143+
propagateSkipPermissions: true,
144+
});
145+
expect(cfg.mcpServers['parallel-code'].args).toContain('--skip-permissions');
146+
});
147+
148+
it('does NOT add --skip-permissions when propagateSkipPermissions is false', () => {
149+
const cfg = buildCoordinatorMCPConfig({
150+
...baseOpts,
151+
skipPermissions: true,
152+
propagateSkipPermissions: false,
153+
});
154+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
155+
});
156+
157+
it('does NOT add --skip-permissions when skipPermissions is false', () => {
158+
const cfg = buildCoordinatorMCPConfig({
159+
...baseOpts,
160+
skipPermissions: false,
161+
propagateSkipPermissions: true,
162+
});
163+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
164+
});
165+
166+
it('JSON-serialised output is valid JSON with the parallel-code key', () => {
167+
const cfg = buildCoordinatorMCPConfig(baseOpts);
168+
const json = JSON.stringify(cfg, null, 2);
169+
const parsed = JSON.parse(json) as typeof cfg;
170+
expect(parsed.mcpServers['parallel-code']).toBeDefined();
171+
});
172+
});

0 commit comments

Comments
 (0)