Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,22 @@ jobs:
# env locally because it takes ~20s and needs a working shell.
- name: Test (coordinator against a real pty)
run: npm run test:coordinator-pty

windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run compile
- run: npm run typecheck
- run: npm run lint
- run: npm run test:windows
- run: npm run build:win
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: parallel-code-windows-x64
path: release/Parallel-Code-Windows-x64-Setup.exe
10 changes: 9 additions & 1 deletion electron/agent-hooks/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { buildClaudeHookSettings } from './claude-settings.js';

describe('buildClaudeHookSettings', () => {
const settings = buildClaudeHookSettings('/Users/me/Library/App Support/hook.sh');
const settings = buildClaudeHookSettings('/Users/me/Library/App Support/hook.sh', 'darwin');

it('registers turn, tool, and notification events', () => {
expect(Object.keys(settings.hooks).sort()).toEqual(
Expand Down Expand Up @@ -38,6 +38,14 @@ describe('buildClaudeHookSettings', () => {
});
});

it('uses PowerShell for hooks on native Windows', () => {
const hook = buildClaudeHookSettings('C:\\Program Files\\Parallel Code\\hook.ps1', 'win32')
.hooks.Stop[0].hooks[0];
expect(hook.command).toBe(
'"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\\Program Files\\Parallel Code\\hook.ps1"',
);
});

it('does not register compaction hooks, which fire mid-turn', () => {
expect(settings.hooks.PreCompact).toBeUndefined();
expect(settings.hooks.PostCompact).toBeUndefined();
Expand Down
9 changes: 7 additions & 2 deletions electron/agent-hooks/claude-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,15 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}

export function buildClaudeHookSettings(hookScriptPath: string): ClaudeHookSettings {
export function buildClaudeHookSettings(
hookScriptPath: string,
platform: NodeJS.Platform = process.platform,
): ClaudeHookSettings {
const hook: CommandHook = {
type: 'command',
command: `/bin/sh ${shellQuote(hookScriptPath)}`,
command: platform === 'win32'
? `"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${hookScriptPath}"`
: `/bin/sh ${shellQuote(hookScriptPath)}`,
timeout: HOOK_TIMEOUT_SECONDS,
};
const hooks: Record<string, HookGroup[]> = {};
Expand Down
29 changes: 29 additions & 0 deletions electron/agent-hooks/hook-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ exit 0
`;
}

/** Native Windows hook: PowerShell is present on Windows 10/11. The endpoint
* file is read for each event so agents survive an Electron restart. */
export function buildWindowsHookScript(): string {
return `# Generated by Parallel Code; fail open if the receiver is unavailable.
[Console]::Out.WriteLine('{}')
try {
$payload = [Console]::In.ReadToEnd()
if (-not $payload -or $env:CLAUDE_JOB_DIR) { exit 0 }
$endpoint = $env:${HOOK_ENV_ENDPOINT}
$agent = $env:${HOOK_ENV_AGENT_ID}
if (-not $endpoint -or -not $agent) { exit 0 }
$settings = @{}
foreach ($line in [IO.File]::ReadAllLines($endpoint)) {
if ($line -match '^([A-Z_]+)=(.*)$') { $settings[$matches[1]] = $matches[2] }
}
$port = $settings['${HOOK_ENV_PORT}']
$token = $settings['${HOOK_ENV_TOKEN}']
if (-not $port -or -not $token) { exit 0 }
$headers = @{
'${HOOK_TOKEN_HEADER}' = $token
'${HOOK_AGENT_ID_HEADER}' = $agent
'${HOOK_TASK_ID_HEADER}' = $env:${HOOK_ENV_TASK_ID}
}
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "http://127.0.0.1:$port/hook/claude" -Headers $headers -ContentType 'application/json' -Body $payload -TimeoutSec 2 | Out-Null
} catch { }
exit 0
`;
}

/** Contents of the endpoint file the script sources on every event. */
export function buildEndpointFile(port: number, token: string): string {
return `${HOOK_ENV_PORT}=${port}\n${HOOK_ENV_TOKEN}=${token}\n`;
Expand Down
4 changes: 2 additions & 2 deletions electron/agent-hooks/launch-args.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import path from 'path';
import { commandName } from '../shared/command-name.js';

/** True for `claude` and absolute paths to it; wrappers with other names get nothing. */
export function isClaudeCommand(command: string): boolean {
return path.basename(command) === 'claude';
return commandName(command) === 'claude';
}

/**
Expand Down
14 changes: 10 additions & 4 deletions electron/agent-hooks/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
HOOK_TOKEN_HEADER,
buildEndpointFile,
buildHookScript,
buildWindowsHookScript,
} from './hook-script.js';
import { mapClaudeHookPayload, type AgentHookEventPayload } from './status.js';

Expand Down Expand Up @@ -81,18 +82,23 @@ function writeFiles(
): Pick<AgentHookServer, 'hookScriptPath' | 'claudeSettingsPath'> {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const endpointPath = path.join(dir, 'endpoint.env');
const hookScriptPath = path.join(dir, 'hook.sh');
const isWindows = process.platform === 'win32';
const hookScriptPath = path.join(dir, isWindows ? 'hook.ps1' : 'hook.sh');
const claudeSettingsPath = path.join(dir, 'claude-settings.json');
fs.writeFileSync(endpointPath, buildEndpointFile(port, token), { mode: 0o600 });
fs.writeFileSync(hookScriptPath, buildHookScript(), { mode: 0o755 });
fs.writeFileSync(hookScriptPath, isWindows ? buildWindowsHookScript() : buildHookScript(), {
mode: 0o755,
});
fs.writeFileSync(
claudeSettingsPath,
JSON.stringify(buildClaudeHookSettings(hookScriptPath), null, 2) + '\n',
);
// `mode` only applies on creation; a directory or token file left over from
// an older build (or loosened by hand) must be tightened again every launch.
fs.chmodSync(dir, 0o700);
fs.chmodSync(endpointPath, 0o600);
if (!isWindows) {
fs.chmodSync(dir, 0o700);
fs.chmodSync(endpointPath, 0o600);
}
return { hookScriptPath, claudeSettingsPath };
}

Expand Down
111 changes: 111 additions & 0 deletions electron/command-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import * as pty from 'node-pty';
import { resolveCommand, windowsPtyCommand } from './command-path.js';

const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});

describe.skipIf(process.platform !== 'win32')('Windows command resolution', () => {
it('finds npm shims and native CLIs through PATHEXT, including spaced paths', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel commands '));
dirs.push(dir);
for (const name of ['claude.cmd', 'codex.exe', 'agy.exe']) {
fs.writeFileSync(path.join(dir, name), '');
}
const env = { Path: dir, PATHEXT: '.EXE;.CMD' };
expect(resolveCommand('claude', 'win32', env)).toBe(path.join(dir, 'claude.cmd'));
expect(resolveCommand('codex', 'win32', env)).toBe(path.join(dir, 'codex.exe'));
expect(resolveCommand('agy', 'win32', env)).toBe(path.join(dir, 'agy.exe'));
expect(resolveCommand(path.join(dir, 'claude.cmd'), 'win32', env)).toBe(
path.join(dir, 'claude.cmd'),
);
expect(() => resolveCommand('missing', 'win32', env)).toThrow(/not found/);
});

it('starts .cmd shims through cmd.exe and leaves native executables alone', () => {
const wrapped = windowsPtyCommand('C:\\Program Files\\Agent\\claude.cmd', ['hello world']);
expect(wrapped.command.toLowerCase()).toMatch(/cmd\.exe$/);
expect(wrapped.args).toEqual([
'/d', '/s', '/c', 'C:\\Program Files\\Agent\\claude.cmd', 'hello world',
]);
expect(windowsPtyCommand('C:\\tools\\agy.exe', ['x'])).toEqual({
command: 'C:\\tools\\agy.exe', args: ['x'],
});
});

it('runs a .cmd shim in ConPTY with a spaced path and argument', async () => {
const baseDir = process.env.SystemDrive ? path.join(process.env.SystemDrive + '\\', 'Temp') : os.tmpdir();
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
const dir = fs.mkdtempSync(path.join(baseDir, 'par-pty-'));
dirs.push(dir);
const shim = path.join(dir, 'claude.cmd');
fs.writeFileSync(shim, '@echo off\r\necho READY:%~1\r\n');
const launch = windowsPtyCommand(shim, ['hello world']);
const sanitizedEnv = { ...process.env };
for (const key of Object.keys(sanitizedEnv)) {
if (typeof sanitizedEnv[key] === 'string' && (sanitizedEnv[key]!.startsWith('\\\\') || sanitizedEnv[key]!.includes('Meu Drive'))) {
delete sanitizedEnv[key];
}
}
const output = await new Promise<string>((resolve, reject) => {
const proc = pty.spawn(launch.command, launch.args, {
cwd: dir,
env: sanitizedEnv as Record<string, string>,
cols: 80,
rows: 24,
useConpty: true,
});
let text = '';
const timeout = setTimeout(() => {
proc.kill();
reject(new Error(`ConPTY timed out: ${text}`));
}, 5000);
proc.onData((data) => { text += data; });
proc.onExit(() => { clearTimeout(timeout); resolve(text); });
});
expect(output).toContain('READY:hello world');
});
it('handles complex prompts with special characters (&, %, ^, quotes, accents, spaces)', async () => {
const baseDir = process.env.SystemDrive ? path.join(process.env.SystemDrive + '\\', 'Temp') : os.tmpdir();
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
const dir = fs.mkdtempSync(path.join(baseDir, 'par-pty-spec-'));
dirs.push(dir);
const shim = path.join(dir, 'test-prompt.cmd');
const outFile = path.join(dir, 'received.txt');
fs.writeFileSync(shim, `@echo off\r\necho %* > "${outFile}"\r\necho PROMPT:%*\r\n`);
const complexArg = 'Fix "bug" & test %PATH% ^ (ç ã é) "spaced path"';
const launch = windowsPtyCommand(shim, [complexArg]);
const sanitizedEnv = { ...process.env };
for (const key of Object.keys(sanitizedEnv)) {
if (typeof sanitizedEnv[key] === 'string' && (sanitizedEnv[key]!.startsWith('\\\\') || sanitizedEnv[key]!.includes('Meu Drive'))) {
delete sanitizedEnv[key];
}
}
await new Promise<string>((resolve, reject) => {
const proc = pty.spawn(launch.command, launch.args, {
cwd: dir,
env: sanitizedEnv as Record<string, string>,
cols: 80,
rows: 24,
useConpty: true,
});
let text = '';
const timeout = setTimeout(() => {
proc.kill();
reject(new Error(`ConPTY timed out: ${text}`));
}, 5000);
proc.onData((data) => { text += data; });
proc.onExit(() => { clearTimeout(timeout); resolve(text); });
});
expect(fs.existsSync(outFile)).toBe(true);
const receivedContent = fs.readFileSync(outFile, 'utf8');
expect(receivedContent).toContain('Fix');
expect(receivedContent).toContain('bug');
expect(receivedContent).toContain('spaced path');
});
});
52 changes: 52 additions & 0 deletions electron/command-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';

/** Resolve the exact file handed to a process launcher. Windows CreateProcess
* does not search PATHEXT for us, and a .cmd file is not a native executable. */
export function resolveCommand(
command: string,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): string {
if (!command.trim()) throw new Error('Command must not be empty.');
if (platform !== 'win32') {
if (path.posix.isAbsolute(command)) {
fs.accessSync(command, fs.constants.X_OK);
return command;
}
return execFileSync('which', [command], { encoding: 'utf8', timeout: 3000 }).trim() || command;
}

const extensions = (env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
.split(';')
.filter(Boolean)
.map((ext) => ext.toLowerCase());
const hasExtension = path.win32.extname(command) !== '';
const candidates = hasExtension ? [command] : extensions.map((ext) => command + ext);
const pathValue = env.Path ?? env.PATH ?? env.path ?? '';
const dirs = path.win32.isAbsolute(command) || /[\\/]/.test(command)
? ['']
: pathValue.split(';').filter(Boolean);
for (const dir of dirs) {
for (const candidate of candidates) {
const full = dir ? path.win32.join(dir, candidate) : path.win32.resolve(candidate);
try {
if (fs.statSync(full).isFile()) return full;
} catch {
// Try the next PATHEXT candidate.
}
}
}
throw new Error(`Command '${command}' not found in PATH.`);
}

/** A script shim must be started through cmd.exe under ConPTY. Keep each argv
* element quoted so spaces and cmd metacharacters cannot turn into a command. */
export function windowsPtyCommand(command: string, args: string[]): { command: string; args: string[] } {
if (!/\.(cmd|bat)$/i.test(command)) return { command, args };
return {
command: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', command, ...args],
};
}
25 changes: 11 additions & 14 deletions electron/documents/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { IPC } from '../ipc/channels.js';
import { errMessage } from '../log.js';
import { atomicWriteFileSync } from '../mcp/atomic.js';
import { buildPtySpawnEnv, validateCommand } from '../ipc/pty.js';
import { resolveCommand, windowsPtyCommand } from '../command-path.js';
import { killProcessTree } from '../process-tree.js';
import { loadEnvFile } from '../ipc/env-file.js';
import { truncateBytes } from './prompt.js';
import { buildHeadlessLaunch, createHeadlessParser } from './agents.js';
Expand Down Expand Up @@ -331,17 +333,9 @@ export function buildAnnotationPrompt(
}

function killAsk(entry: ActiveAsk): void {
const pid = entry.proc.pid;
const signal = (sig: NodeJS.Signals) => {
try {
if (pid) process.kill(-pid, sig);
else entry.proc.kill(sig);
} catch {
// Already gone.
}
};
signal('SIGTERM');
setTimeout(() => signal('SIGKILL'), KILL_GRACE_MS).unref?.();
killProcessTree(entry.proc, 'SIGTERM');
if (process.platform !== 'win32')
setTimeout(() => killProcessTree(entry.proc, 'SIGKILL'), KILL_GRACE_MS).unref?.();
}

export function cancelAsk(annotationId: string): void {
Expand Down Expand Up @@ -373,7 +367,7 @@ export async function askAnnotation(
throw new Error(`Agent "${agentId}" has no headless mode for document questions.`);
const agentName = text(args.agentName, 'agentName', 64);
const command = text(args.command, 'command', 200);
if (!command.trim() || /[\s;&|<>$`'"\\]/.test(command)) throw new Error('command is invalid');
if (!command.trim() || /[;&|<>$`'"\r\n]/.test(command)) throw new Error('command is invalid');
const envFile = args.envFile === undefined ? undefined : text(args.envFile, 'envFile', 1_000);
validateCommand(command);

Expand All @@ -395,11 +389,14 @@ export async function askAnnotation(
});
const env = buildPtySpawnEnv({}, envFile?.trim() ? loadEnvFile(envFile) : {});
const parser = createHeadlessParser(agentId);
const proc = spawn(launch.command, launch.args, {
const nativeLaunch = process.platform === 'win32'
? windowsPtyCommand(resolveCommand(launch.command), launch.args)
: { command: resolveCommand(launch.command), args: launch.args };
const proc = spawn(nativeLaunch.command, nativeLaunch.args, {
cwd: projectRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
detached: process.platform !== 'win32',
});
proc.stdout?.setEncoding('utf8');
proc.stderr?.setEncoding('utf8');
Expand Down
Loading