diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb68fe4f0..9a51df9ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/electron/agent-hooks/claude-settings.test.ts b/electron/agent-hooks/claude-settings.test.ts index ee28cbf94..a5671da36 100644 --- a/electron/agent-hooks/claude-settings.test.ts +++ b/electron/agent-hooks/claude-settings.test.ts @@ -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( @@ -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(); diff --git a/electron/agent-hooks/claude-settings.ts b/electron/agent-hooks/claude-settings.ts index 274cffc39..5a6a1fdc1 100644 --- a/electron/agent-hooks/claude-settings.ts +++ b/electron/agent-hooks/claude-settings.ts @@ -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 = {}; diff --git a/electron/agent-hooks/hook-script.ts b/electron/agent-hooks/hook-script.ts index c17d77fe8..632368dcd 100644 --- a/electron/agent-hooks/hook-script.ts +++ b/electron/agent-hooks/hook-script.ts @@ -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`; diff --git a/electron/agent-hooks/launch-args.ts b/electron/agent-hooks/launch-args.ts index 1d06d2d44..5b231bfaf 100644 --- a/electron/agent-hooks/launch-args.ts +++ b/electron/agent-hooks/launch-args.ts @@ -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'; } /** diff --git a/electron/agent-hooks/server.ts b/electron/agent-hooks/server.ts index 430407820..de0f5428c 100644 --- a/electron/agent-hooks/server.ts +++ b/electron/agent-hooks/server.ts @@ -12,6 +12,7 @@ import { HOOK_TOKEN_HEADER, buildEndpointFile, buildHookScript, + buildWindowsHookScript, } from './hook-script.js'; import { mapClaudeHookPayload, type AgentHookEventPayload } from './status.js'; @@ -81,18 +82,23 @@ function writeFiles( ): Pick { 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 }; } diff --git a/electron/command-path.test.ts b/electron/command-path.test.ts new file mode 100644 index 000000000..1f2fccea6 --- /dev/null +++ b/electron/command-path.test.ts @@ -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((resolve, reject) => { + const proc = pty.spawn(launch.command, launch.args, { + cwd: dir, + env: sanitizedEnv as Record, + 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((resolve, reject) => { + const proc = pty.spawn(launch.command, launch.args, { + cwd: dir, + env: sanitizedEnv as Record, + 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'); + }); +}); diff --git a/electron/command-path.ts b/electron/command-path.ts new file mode 100644 index 000000000..15ef19aa6 --- /dev/null +++ b/electron/command-path.ts @@ -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], + }; +} diff --git a/electron/documents/annotations.ts b/electron/documents/annotations.ts index 716e4eebe..2e1b78276 100644 --- a/electron/documents/annotations.ts +++ b/electron/documents/annotations.ts @@ -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'; @@ -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 { @@ -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); @@ -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'); diff --git a/electron/documents/runs.ts b/electron/documents/runs.ts index a68f49eb6..db02d8155 100644 --- a/electron/documents/runs.ts +++ b/electron/documents/runs.ts @@ -14,6 +14,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 { createWorktree, ensureWorktreeContainerExclude, removeWorktree } from '../ipc/git.js'; import { git, gitOk } from './git.js'; @@ -136,7 +138,7 @@ function validateCandidateSpecs(value: unknown): DocumentCandidateSpec[] { if (!documentAgentSupport(agentId).headless) throw new Error(`Agent "${agentId}" has no headless mode for document runs.`); const command = str('command'); - if (/[\s;&|<>$`'"\\]/.test(command)) throw new Error('candidate.command is invalid'); + if (/[;&|<>$`'"\r\n]/.test(command)) throw new Error('candidate.command is invalid'); // Session ids and shas are handed to CLIs and git as positional values; // a leading dash would turn them into flags. const sessionId = optStr('sessionId'); @@ -490,21 +492,10 @@ function mainSessionBusy(projectRoot: string): boolean { function killCandidate(entry: ActiveCandidate): void { const pid = entry.proc.pid; if (!pid || entry.proc.exitCode !== null || entry.proc.signalCode !== null) return; - const signal = (sig: NodeJS.Signals) => { - try { - // Negative pid: the process group created by `detached: true`. - process.kill(-pid, sig); - } catch { - try { - entry.proc.kill(sig); - } catch { - // Already gone. - } - } - }; - signal('SIGTERM'); + killProcessTree(entry.proc, 'SIGTERM'); + if (process.platform === 'win32') return; if (!entry.killTimer) { - entry.killTimer = setTimeout(() => signal('SIGKILL'), KILL_GRACE_MS); + entry.killTimer = setTimeout(() => killProcessTree(entry.proc, 'SIGKILL'), KILL_GRACE_MS); entry.killTimer.unref?.(); } } @@ -938,12 +929,15 @@ function spawnCandidate( const env = buildPtySpawnEnv({}, fileEnv); const parser = createHeadlessParser(spec.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: candidate.worktreePath, env, stdio: ['ignore', 'pipe', 'pipe'], // Own process group, so cancelling reaches the CLI's children too. - detached: true, + detached: process.platform !== 'win32', }); proc.stdout?.setEncoding('utf8'); proc.stderr?.setEncoding('utf8'); diff --git a/electron/ipc/agents.ts b/electron/ipc/agents.ts index e58b60be9..d899f0a93 100644 --- a/electron/ipc/agents.ts +++ b/electron/ipc/agents.ts @@ -1,8 +1,5 @@ -import { execFile } from 'child_process'; -import { promisify } from 'util'; import { getSkipPermissionsArgs } from '../shared/skip-permissions.js'; - -const execFileAsync = promisify(execFile); +import { resolveCommand } from '../command-path.js'; interface AgentDef { id: string; @@ -81,14 +78,14 @@ const DEFAULT_AGENTS: AgentDef[] = [ async function isCommandAvailable(command: string): Promise { try { - await execFileAsync('which', [command], { encoding: 'utf8', timeout: 3000 }); + resolveCommand(command); return true; } catch { return false; } } -// TTL cache to avoid repeated `which` calls +// TTL cache to avoid repeated filesystem/PATH checks let cachedAgents: AgentDef[] | null = null; let cacheTime = 0; const AGENT_CACHE_TTL = 30_000; diff --git a/electron/ipc/ask-code.ts b/electron/ipc/ask-code.ts index e8bcc568e..3003c01b4 100644 --- a/electron/ipc/ask-code.ts +++ b/electron/ipc/ask-code.ts @@ -1,6 +1,8 @@ import { spawn, type ChildProcess } from 'child_process'; import type { BrowserWindow } from 'electron'; import { validateCommand, ENV_BLOCK_LIST } from './pty.js'; +import { resolveCommand, windowsPtyCommand } from '../command-path.js'; +import { killProcessTree } from '../process-tree.js'; import { loadEnvFile } from './env-file.js'; import { askAboutCodeMinimax, @@ -68,9 +70,8 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { delete filteredEnv.CLAUDE_CODE_SESSION; delete filteredEnv.CLAUDE_CODE_ENTRYPOINT; - const proc = spawn( - 'claude', - [ + const launch = process.platform === 'win32' + ? windowsPtyCommand(resolveCommand('claude'), [ '-p', prompt, '--output-format', @@ -83,13 +84,17 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { '--no-session-persistence', '--append-system-prompt', 'Answer concisely about the selected code. Use markdown.', - ], - { + ]) + : { command: resolveCommand('claude'), args: [ + '-p', prompt, '--output-format', 'text', '--model', 'sonnet', '--tools', '', + '--no-session-persistence', '--append-system-prompt', + 'Answer concisely about the selected code. Use markdown.', + ] }; + const proc = spawn(launch.command, launch.args, { cwd, env: filteredEnv, stdio: ['ignore', 'pipe', 'pipe'], - }, - ); + }); const send = (msg: unknown) => { if (!win.isDestroyed()) { @@ -98,7 +103,7 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { }; const session = AskCodeSession.start(activeRequests, requestId, proc, send, (request) => - request.kill('SIGTERM'), + killProcessTree(request), ); proc.stdout?.on('data', (chunk: Buffer) => { diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index 8b5531f57..7bfd3305f 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -891,7 +891,7 @@ export async function createWorktree( baseBranch?: string, forceClean = false, ): Promise<{ path: string; branch: string }> { - const worktreePath = `${repoRoot}/.worktrees/${branchName}`; + const worktreePath = path.join(repoRoot, '.worktrees', branchName); if (forceClean) { // Clean up stale worktree/branch from a previous session that wasn't properly removed @@ -971,7 +971,16 @@ export async function createWorktree( // the only path agent sandboxes allow writes to. if (!ensureNodeModulesEntryLinks(source, target)) continue; } else { - fs.symlinkSync(source, target); + const sourceStat = fs.statSync(source); + try { + fs.symlinkSync(source, target, sourceStat.isDirectory() + ? (process.platform === 'win32' ? 'junction' : 'dir') + : 'file'); + } catch (err) { + if (process.platform !== 'win32' || sourceStat.isDirectory()) throw err; + // Developer Mode may be disabled. A file copy is independent and safe. + fs.copyFileSync(source, target); + } } createdSymlinks.push(name); } catch (err) { @@ -1219,7 +1228,7 @@ export async function removeWorktree( // After the user adopts a branch the agent switched the worktree to, the // folder keeps its original branch-derived name — callers that know the real // path must pass it, deriving from branchName is only a fallback. - const worktreePath = explicitWorktreePath ?? `${repoRoot}/.worktrees/${branchName}`; + const worktreePath = explicitWorktreePath ?? path.join(repoRoot, '.worktrees', branchName); if (!fs.existsSync(repoRoot)) return; diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index 2a938e758..abde3a0b7 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'url'; import type { BrowserWindow } from 'electron'; import { RingBuffer } from '../remote/ring-buffer.js'; import { resolveUserShell } from '../user-shell.js'; +import { resolveCommand, windowsPtyCommand } from '../command-path.js'; import { detectRepoRoot, ensureClaudeSandboxFiles, @@ -210,20 +211,8 @@ export function validateCommand(command: string): void { if (!command || !command.trim()) { throw new Error('Command must not be empty.'); } - // Absolute paths: check directly via filesystem - if (command.startsWith('/')) { - try { - fs.accessSync(command, fs.constants.X_OK); - return; - } catch { - throw new Error( - `Command '${command}' not found or not executable. Check that it is installed.`, - ); - } - } - // Bare names: resolve via `which` (execFileSync — no shell interpolation) try { - execFileSync('which', [command], { encoding: 'utf8', timeout: 3000 }); + resolveCommand(command); } catch { throw new Error( `Command '${command}' not found in PATH. Make sure it is installed and available in your terminal.`, @@ -518,7 +507,7 @@ export function applyAgentHookLaunch( export function spawnAgent(win: BrowserWindow, args: SpawnAgentArgs): void { const channelId = args.onOutput.__CHANNEL_ID__; const command = args.command || resolveUserShell(); - const cwd = args.cwd || process.env.HOME || '/'; + const cwd = args.cwd || process.env.HOME || process.env.USERPROFILE || process.cwd(); // Renderer reloads should reattach to still-running PTYs before validating // the launch command. The process already exists; a missing binary after @@ -577,7 +566,13 @@ export function spawnAgent(win: BrowserWindow, args: SpawnAgentArgs): void { refreshWorktreeNodeModules(cwd, repoRoot); } - const spawnSpec = buildPtySpawnSpec({ ...args, args: launchArgs }, command, cwd, spawnEnv); + const nativeCommand = args.dockerMode ? command : resolveCommand(command); + const nativeLaunch = process.platform === 'win32' && !args.dockerMode + ? windowsPtyCommand(nativeCommand, launchArgs) + : { command: nativeCommand, args: launchArgs }; + const spawnSpec = buildPtySpawnSpec( + { ...args, args: nativeLaunch.args }, nativeLaunch.command, cwd, spawnEnv, + ); logDebug('pty', `spawn command ${args.agentId}`, { taskId: args.taskId, @@ -593,6 +588,7 @@ export function spawnAgent(win: BrowserWindow, args: SpawnAgentArgs): void { rows: args.rows, cwd: spawnSpec.cwd, env: spawnSpec.env, + useConpty: process.platform === 'win32', }); const session: PtySession = { diff --git a/electron/ipc/verify.ts b/electron/ipc/verify.ts index 70bfdcf41..2cb72f1d4 100644 --- a/electron/ipc/verify.ts +++ b/electron/ipc/verify.ts @@ -2,6 +2,7 @@ import { execFile, spawn } from 'child_process'; import { existsSync } from 'fs'; import { promisify } from 'util'; import { resolveUserShell } from '../user-shell.js'; +import { killProcessTree as terminateTree } from '../process-tree.js'; import { stripAnsi } from '../shared/prompt-detect.js'; import { pendingVerificationRun } from '../shared/verification-run.js'; import type { VerificationRun, VerificationRunStatus } from './shared-types.js'; @@ -107,17 +108,9 @@ function appendTail(tail: string, chunk: string): string { } function killProcessTree(child: Child): void { - const pid = child.pid; - const signalGroup = (signal: NodeJS.Signals) => { - try { - if (pid && process.platform !== 'win32') process.kill(-pid, signal); - else child.kill(signal); - } catch { - /* already gone */ - } - }; - signalGroup('SIGTERM'); - const hardKill = setTimeout(() => signalGroup('SIGKILL'), KILL_GRACE_MS); + terminateTree(child, 'SIGTERM'); + if (process.platform === 'win32') return; + const hardKill = setTimeout(() => terminateTree(child, 'SIGKILL'), KILL_GRACE_MS); hardKill.unref?.(); child.once('close', () => clearTimeout(hardKill)); } @@ -132,7 +125,13 @@ function exitOutcome(code: number | null, signal: NodeJS.Signals | null): EndRea } function spawnCommand(request: VerifyRequest, deps: SpawnDeps): Child { - return deps.spawnImpl(deps.shell, ['-c', request.command], { + const windows = process.platform === 'win32'; + const shellArgs = windows + ? (/powershell(?:\.exe)?$/i.test(deps.shell) + ? ['-NoProfile', '-NonInteractive', '-Command', request.command] + : ['/d', '/s', '/c', request.command]) + : ['-c', request.command]; + return deps.spawnImpl(deps.shell, shellArgs, { cwd: request.worktreePath, env: { ...process.env, ...request.env, NO_COLOR: '1', FORCE_COLOR: '0' }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/electron/ipc/worktree-cleanup.ts b/electron/ipc/worktree-cleanup.ts index 59401747d..6634314f2 100644 --- a/electron/ipc/worktree-cleanup.ts +++ b/electron/ipc/worktree-cleanup.ts @@ -94,6 +94,17 @@ export async function reclaimOwnership( uid: number, gid: number, ): Promise { + if (process.platform === 'win32') { + try { + const systemRoot = process.env.SystemRoot || 'C:\\Windows'; + const attribPath = path.join(systemRoot, 'System32', 'attrib.exe'); + await exec(attribPath, ['-r', '-h', '-s', path.join(worktreePath, '*'), '/s', '/d'], { timeout: 15_000 }); + return null; + } catch (e: unknown) { + return firstLine(e); + } + } + // The two cases where no correct `docker run` can be built: `-v host:container` // has no escape for a colon in the host path, and chown needs a real uid. if (worktreePath.includes(':')) return 'the worktree path contains ":"'; @@ -122,8 +133,6 @@ export async function reclaimOwnership( { timeout: CHOWN_TIMEOUT_MS }, ); - // `--pull never` keeps the agent-image attempt from stalling on a registry - // lookup when the image was never built on this machine. try { await run(AGENT_IMAGE, true); return null; @@ -152,11 +161,14 @@ export function foreignOwnedRemovalError( .map((e) => path.relative(worktreePath, e.path) || '.') .join(', '); const uids = [...new Set(entries.map((e) => e.uid))].join(', '); + const manualCmd = process.platform === 'win32' + ? `rmdir /s /q "${worktreePath}"` + : `sudo rm -rf "${worktreePath}"`; return new Error( - `Cannot remove worktree "${worktreePath}": it contains files owned by uid ${uids} ` + - `(e.g. ${examples}), left behind by a container that ran as root. ` + - `Automatic cleanup via Docker failed: ${reclaimFailure}. ` + - `Remove them manually, then close the task again: sudo rm -rf "${worktreePath}"`, + `Cannot remove worktree "${worktreePath}": it contains locked/foreign entries ` + + `(e.g. ${examples}, uid: ${uids}). ` + + `Automatic cleanup failed: ${reclaimFailure}. ` + + `Remove them manually, then close the task again: ${manualCmd}`, ); } diff --git a/electron/ipc/worktree-node-modules.ts b/electron/ipc/worktree-node-modules.ts index 82b6b80d6..721d9cde1 100644 --- a/electron/ipc/worktree-node-modules.ts +++ b/electron/ipc/worktree-node-modules.ts @@ -148,8 +148,16 @@ export function ensureNodeModulesEntryLinks(sourceDir: string, targetDir: string try { // Relative links survive the repo being moved or reached through a // different path alias. - const dest = path.relative(targetDir, path.join(sourceDir, name)); - fs.symlinkSync(dest, path.join(targetDir, name)); + const source = path.join(sourceDir, name); + const dest = process.platform === 'win32' ? source : path.relative(targetDir, source); + const isDirectory = fs.statSync(source).isDirectory(); + try { + fs.symlinkSync(dest, path.join(targetDir, name), + isDirectory ? (process.platform === 'win32' ? 'junction' : 'dir') : 'file'); + } catch (err) { + if (process.platform !== 'win32' || isDirectory) throw err; + fs.copyFileSync(source, path.join(targetDir, name)); + } } catch (err) { console.warn(`Failed to link node_modules entry '${name}':`, err); } diff --git a/electron/mcp/agent-args.ts b/electron/mcp/agent-args.ts index 5add7eab3..1cf7b4f29 100644 --- a/electron/mcp/agent-args.ts +++ b/electron/mcp/agent-args.ts @@ -1,3 +1,5 @@ +import { commandName } from '../shared/command-name.js'; + interface ParallelCodeMcpServerConfig { command: string; args: string[]; @@ -11,15 +13,15 @@ export interface ParallelCodeMcpConfig { } export function isCodexCommand(command: string): boolean { - return command.split('/').pop()?.includes('codex') === true; + return commandName(command).includes('codex'); } export function isAntigravityCommand(command: string): boolean { - return command.split('/').pop() === 'agy'; + return commandName(command) === 'agy'; } export function isCopilotCommand(command: string): boolean { - return command.split('/').pop() === 'copilot'; + return commandName(command) === 'copilot'; } function tomlString(value: string): string { diff --git a/electron/process-tree.ts b/electron/process-tree.ts new file mode 100644 index 000000000..c42018497 --- /dev/null +++ b/electron/process-tree.ts @@ -0,0 +1,22 @@ +import { execFile } from 'child_process'; +import type { ChildProcess } from 'child_process'; + +/** Windows has no POSIX process groups. taskkill /T terminates descendants of + * a shell or CLI as well as the parent, without involving cmd interpolation. */ +export function killProcessTree(child: ChildProcess, signal: NodeJS.Signals = 'SIGTERM'): void { + const pid = child.pid; + if (!pid) return; + if (process.platform === 'win32') { + execFile('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { timeout: 5000 }, (err) => { + if (err) { + try { child.kill(); } catch { /* already gone */ } + } + }); + return; + } + try { + process.kill(-pid, signal); + } catch { + try { child.kill(signal); } catch { /* already gone */ } + } +} diff --git a/electron/shared/command-name.ts b/electron/shared/command-name.ts new file mode 100644 index 000000000..de6a7a006 --- /dev/null +++ b/electron/shared/command-name.ts @@ -0,0 +1,6 @@ +/** Basename of a CLI command on either host OS, without Windows shim suffixes. */ +export function commandName(command: string): string { + return (command.split(/[\\/]/).filter(Boolean).pop() ?? command) + .replace(/\.(cmd|exe|bat)$/i, '') + .toLowerCase(); +} diff --git a/electron/shared/skip-permissions.ts b/electron/shared/skip-permissions.ts index 575140774..eaf23d09e 100644 --- a/electron/shared/skip-permissions.ts +++ b/electron/shared/skip-permissions.ts @@ -15,6 +15,8 @@ * named `constructor` or `toString` would read back a prototype member instead * of nothing. `Map` has no such keys to inherit. */ +import { commandName } from './command-name.js'; + const SKIP_PERMISSIONS_ARGS = new Map([ ['claude', ['--dangerously-skip-permissions']], ['codex', ['--dangerously-bypass-approvals-and-sandbox']], @@ -36,7 +38,7 @@ const SKIP_PERMISSIONS_ARGS = new Map([ * not be reachable for mutation. */ export function getSkipPermissionsArgs(command: string): string[] { - const basename = command.split('/').filter(Boolean).pop() ?? command; + const basename = commandName(command); return [...(SKIP_PERMISSIONS_ARGS.get(basename) ?? [])]; } diff --git a/electron/user-shell.ts b/electron/user-shell.ts index 1ec8de9c2..2e03299c3 100644 --- a/electron/user-shell.ts +++ b/electron/user-shell.ts @@ -29,6 +29,15 @@ export function resolveUserShell(deps: ResolveUserShellDeps = {}): string { const canUseShell = deps.canUseShell ?? ((shell: string) => platform === 'win32' || isExecutablePosixShell(shell)); + if (platform === 'win32') { + const windowsShell = normalizeShell(env.ComSpec); + const powerShell = normalizeShell(env.PSModulePath) ? 'powershell.exe' : null; + for (const shell of [windowsShell, powerShell, 'cmd.exe']) { + if (shell && canUseShell(shell)) return shell; + } + return 'cmd.exe'; + } + try { const osShell = normalizeShell(userInfo().shell); if (osShell && canUseShell(osShell)) return osShell; @@ -39,5 +48,5 @@ export function resolveUserShell(deps: ResolveUserShellDeps = {}): string { const envShell = normalizeShell(env.SHELL); if (envShell && canUseShell(envShell)) return envShell; - return platform === 'win32' ? 'cmd.exe' : '/bin/sh'; + return '/bin/sh'; } diff --git a/electron/vite.config.electron.ts b/electron/vite.config.electron.ts index c3a90fc16..d535c1fd2 100644 --- a/electron/vite.config.electron.ts +++ b/electron/vite.config.electron.ts @@ -52,6 +52,7 @@ function rendererCspPlugin(): Plugin { export default defineConfig({ base: './', + css: { postcss: {} }, plugins: [solid(), rendererCspPlugin()], clearScreen: false, server: { diff --git a/package.json b/package.json index 78cba411c..87f4a2b8c 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "main": "dist-electron/main.js", "type": "module", "scripts": { - "dev": "npm run compile && npm run build:mcp && concurrently -k \"vite --config electron/vite.config.electron.ts\" \"wait-on http://localhost:1421 && VITE_DEV_SERVER_URL=http://localhost:1421 electron --no-sandbox dist-electron/main.js\"", + "dev": "npm run compile && npm run build:mcp && concurrently -k \"vite --config electron/vite.config.electron.ts\" \"wait-on http://localhost:1421 && node scripts/start-electron-dev.mjs\"", "typecheck": "tsc --noEmit", "compile": "tsc -p electron/tsconfig.json", "build:mcp": "esbuild dist-electron/mcp/server.js --bundle --platform=node --format=cjs --outfile=dist-electron/mcp-server.cjs", - "build:frontend": "NODE_OPTIONS='--max-old-space-size=4096' vite build --config electron/vite.config.electron.ts", + "build:frontend": "node --max-old-space-size=4096 node_modules/vite/bin/vite.js build --config electron/vite.config.electron.ts", "build:remote": "vite build --config src/remote/vite.config.ts", "build": "npm run build:frontend && npm run build:remote && npm run compile && npm run build:mcp && electron-builder", + "build:win": "npm run build:frontend && npm run build:remote && npm run compile && npm run build:mcp && electron-builder --win nsis --x64", "serve": "vite preview --config electron/vite.config.electron.ts", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", @@ -28,6 +29,7 @@ "format:check": "prettier --check .", "test": "npm run test:unit && npm run test:client", "test:unit": "vitest run", + "test:windows": "vitest run electron/command-path.test.ts electron/user-shell.test.ts electron/agent-hooks/launch-args.test.ts electron/agent-hooks/claude-settings.test.ts electron/mcp/agent-args.test.ts src/lib/agent-args.test.ts src/lib/host-path.test.ts", "test:client": "vitest run --config vitest.client.config.ts", "test:coordinator-pty": "RUN_COORDINATOR_PTY_TEST=1 vitest run electron/mcp/coordinator-real-pty.integration.test.ts", "test:coverage": "vitest run --coverage", @@ -154,6 +156,12 @@ } } }, + "win": { + "target": [ + "nsis" + ], + "artifactName": "Parallel-Code-Windows-x64-Setup.${ext}" + }, "mac": { "target": [ "dmg", diff --git a/scripts/start-electron-dev.mjs b/scripts/start-electron-dev.mjs new file mode 100644 index 000000000..015893f07 --- /dev/null +++ b/scripts/start-electron-dev.mjs @@ -0,0 +1,9 @@ +import { spawn } from 'node:child_process'; +import process from 'node:process'; +import electron from 'electron'; + +const child = spawn(electron, ['--no-sandbox', 'dist-electron/main.js'], { + stdio: 'inherit', + env: { ...process.env, VITE_DEV_SERVER_URL: 'http://localhost:1421' }, +}); +child.on('exit', (code) => { process.exitCode = code ?? 1; }); diff --git a/src/arena/BattleScreen.tsx b/src/arena/BattleScreen.tsx index 1e74efa90..99343f10b 100644 --- a/src/arena/BattleScreen.tsx +++ b/src/arena/BattleScreen.tsx @@ -28,13 +28,18 @@ function formatElapsed(ms: number): string { * inside double quotes: ", $, `, and \. Note: ! (history expansion) is a * bash-only feature and not special in POSIX /bin/sh double quotes. */ function buildCommand(template: string, prompt: string): { command: string; args: string[] } { + const windows = navigator.userAgent.includes('Windows'); const escapedPrompt = prompt - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\$/g, '\\$') - .replace(/`/g, '\\`'); + .replace(/\\/g, windows ? '\\' : '\\\\') + .replace(/"/g, windows ? '^"' : '\\"') + .replace(/\$/g, windows ? '$' : '\\$') + .replace(/`/g, windows ? '`' : '\\`') + .replace(/%/g, windows ? '%%' : '%') + .replace(/!/g, windows ? '^!' : '!'); const fullCommand = template.replace(/\{prompt\}/g, escapedPrompt); - return { command: '/bin/sh', args: ['-c', fullCommand] }; + return windows + ? { command: 'cmd.exe', args: ['/d', '/s', '/c', fullCommand] } + : { command: '/bin/sh', args: ['-c', fullCommand] }; } export function BattleScreen() { @@ -92,7 +97,7 @@ export function BattleScreen() { {(competitor, index) => { const { command, args } = buildCommand(competitor.command, arenaStore.prompt); const agentId = competitor.agentId; - const cwd = competitor.worktreePath ?? '/tmp'; + const cwd = competitor.worktreePath ?? arenaStore.cwd ?? ''; return ( <> diff --git a/src/arena/ConfigScreen.tsx b/src/arena/ConfigScreen.tsx index 7f00be2bb..fb3008000 100644 --- a/src/arena/ConfigScreen.tsx +++ b/src/arena/ConfigScreen.tsx @@ -28,6 +28,7 @@ const TOOL_PRESETS: Array<{ name: string; command: string }> = [ { name: 'Claude', command: 'claude -p "{prompt}" --dangerously-skip-permissions' }, { name: 'Codex', command: 'codex exec --dangerously-bypass-approvals-and-sandbox "{prompt}"' }, { name: 'Gemini', command: 'gemini -p "{prompt}" --yolo' }, + { name: 'Antigravity', command: 'agy -p "{prompt}" --dangerously-skip-permissions' }, { name: 'Copilot', command: 'copilot -p "{prompt}" --yolo' }, { name: 'Aider', command: 'aider -m "{prompt}" --yes' }, { name: 'OpenCode', command: 'opencode -p "{prompt}"' }, diff --git a/src/arena/ResultsScreen.tsx b/src/arena/ResultsScreen.tsx index b6b14cc30..f3ac78dbb 100644 --- a/src/arena/ResultsScreen.tsx +++ b/src/arena/ResultsScreen.tsx @@ -1,5 +1,6 @@ import { For, Show, createMemo, createSignal, onMount } from 'solid-js'; import { ChangedFilesList } from '../components/ChangedFilesList'; +import { hostBasename } from '../lib/host-path'; import { DiffViewerDialog } from '../components/DiffViewerDialog'; import { CommitDialog } from './CommitDialog'; import { createMergeWorkflow } from './merge'; @@ -36,7 +37,7 @@ export function ResultsScreen() { const cwd = arenaStore.cwd; if (!cwd) return null; const project = store.projects.find((p) => p.path === cwd); - return project?.name ?? cwd.split('/').pop() ?? null; + return project?.name ?? hostBasename(cwd); }); // When viewing from history, pre-populate ratings from saved match diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 854a99923..414f5ad38 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -7,6 +7,7 @@ import { untrack, Show, } from 'solid-js'; +import { isAbsoluteHostPath } from '../lib/host-path'; import { Terminal, type IMarker } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { WebglAddon } from '@xterm/addon-webgl'; @@ -484,7 +485,7 @@ export function TerminalView(props: TerminalViewProps) { // Strip line:col suffix for opening const filePath = link.text.replace(/:\d+(:\d+)?$/, ''); // Resolve relative paths against the task's working directory - const resolved = filePath.startsWith('/') ? filePath : `${props.cwd}/${filePath}`; + const resolved = isAbsoluteHostPath(filePath) ? filePath : `${props.cwd}/${filePath}`; // .md files open in viewer; Shift held = open externally instead if (/\.md$/i.test(resolved) && props.onFileLink && !event.shiftKey) { props.onFileLink(resolved); diff --git a/src/documents/NewDocumentProjectDialog.tsx b/src/documents/NewDocumentProjectDialog.tsx index 1dfdf6a92..645d68bd6 100644 --- a/src/documents/NewDocumentProjectDialog.tsx +++ b/src/documents/NewDocumentProjectDialog.tsx @@ -12,6 +12,7 @@ import { Dialog } from '../components/Dialog'; import { IPC } from '../../electron/ipc/channels'; import { invoke } from '../lib/ipc'; import { openDialog } from '../lib/dialog'; +import { hostBasename, isAbsoluteHostPath } from '../lib/host-path'; import { errMessage } from '../lib/log'; import { theme, sectionLabelStyle } from '../lib/theme'; import { addDocumentProject } from '../store/projects'; @@ -38,7 +39,7 @@ function preferredDocument(files: DocumentFileInfo[]): string | undefined { } function folderName(folder: string): string { - return folder.replace(/\/+$/, '').split('/').pop() ?? folder; + return hostBasename(folder); } /** `Onboarding flow` → `onboarding-flow`, the file a new project is named after. */ @@ -154,7 +155,7 @@ export function NewDocumentProjectDialog(props: NewDocumentProjectDialogProps) { const enclosingRepo = () => info()?.enclosingRepo ?? null; const canCreate = () => - folder().trim().startsWith('/') && + isAbsoluteHostPath(folder().trim()) && documentPath().trim() && projectName().trim() && !busy() && @@ -228,7 +229,7 @@ export function NewDocumentProjectDialog(props: NewDocumentProjectDialogProps) { The folder path has to be absolute.} > diff --git a/src/lib/agent-args.ts b/src/lib/agent-args.ts index e2742cb48..448d7c39c 100644 --- a/src/lib/agent-args.ts +++ b/src/lib/agent-args.ts @@ -2,17 +2,18 @@ import type { AgentDef } from '../ipc/types'; import type { Task } from '../store/types'; import { resolveSkipPermissionsArgs } from '../../electron/shared/skip-permissions'; import { isDocumentAgentTaskId } from '../documents/task-id'; +import { commandName } from '../../electron/shared/command-name'; function isCodexCommand(command: string): boolean { - return command.split('/').pop()?.includes('codex') === true; + return commandName(command).includes('codex'); } function isAntigravityCommand(command: string): boolean { - return command.split('/').pop() === 'agy'; + return commandName(command) === 'agy'; } function isCopilotCommand(command: string): boolean { - return command.split('/').pop() === 'copilot'; + return commandName(command) === 'copilot'; } const RESUME_FAILURE_PATTERNS: Record = { @@ -20,7 +21,7 @@ const RESUME_FAILURE_PATTERNS: Record = { }; export function isResumeArgsFailure(command: string, lastOutput: string[]): boolean { - const base = command.split('/').pop() ?? command; + const base = commandName(command); const patterns = RESUME_FAILURE_PATTERNS[base]; if (!patterns || lastOutput.length === 0) return false; const text = lastOutput.join('\n'); @@ -46,7 +47,7 @@ export function buildTaskAgentArgs( if (resumed && isDocumentAgentTaskId(task.id ?? null)) { // Document terminals share a checkout. "Latest" may belong to another // terminal: use a picker, without rewriting explicit IDs or custom flags. - const command = agentDef.command.split('/').pop(); + const command = commandName(agentDef.command); const resume = args.join(' '); if (command === 'codex' && resume === 'resume --last') args = ['resume']; if ((command === 'claude' || command === 'copilot') && resume === '--continue') { diff --git a/src/lib/canvas-auto-open.ts b/src/lib/canvas-auto-open.ts index 7e67583d8..bf934c85e 100644 --- a/src/lib/canvas-auto-open.ts +++ b/src/lib/canvas-auto-open.ts @@ -5,6 +5,7 @@ * that never completes (denied permission) is dropped when the map fills. */ import { isPlanApprovalTool } from '../../electron/agent-hooks/status'; +import { isAbsoluteHostPath } from './host-path'; interface HookEventLike { event: string; @@ -27,11 +28,14 @@ const isMarkdown = (p: string): boolean => /\.(md|markdown)$/i.test(p); * a Markdown file inside the worktree (or was clipped by the hook summary). */ export function worktreeMarkdownPath(reported: string, worktreePath: string): string | null { if (!reported || reported.endsWith('…')) return null; - const root = worktreePath.replace(/\/+$/, ''); - let rel = reported; - if (reported.startsWith('/')) { - if (!reported.startsWith(`${root}/`)) return null; - rel = reported.slice(root.length + 1); + const root = worktreePath.replace(/\\/g, '/').replace(/\/+$/, ''); + const normalized = reported.replace(/\\/g, '/'); + let rel = normalized; + if (isAbsoluteHostPath(reported)) { + const inside = normalized.slice(0, root.length + 1); + const expected = `${root}/`; + if (inside.toLowerCase() !== expected.toLowerCase()) return null; + rel = normalized.slice(root.length + 1); } rel = rel.replace(/^\.\//, ''); const segments = rel.split('/'); diff --git a/src/lib/host-path.test.ts b/src/lib/host-path.test.ts new file mode 100644 index 000000000..fad637463 --- /dev/null +++ b/src/lib/host-path.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { hostBasename, isAbsoluteHostPath } from './host-path'; + +describe('host paths', () => { + it('accepts drive, UNC and POSIX roots', () => { + expect(isAbsoluteHostPath('C:\\My Projects\\app')).toBe(true); + expect(isAbsoluteHostPath('D:/code/app')).toBe(true); + expect(isAbsoluteHostPath('\\\\server\\share\\app')).toBe(true); + expect(isAbsoluteHostPath('/home/me/app')).toBe(true); + expect(isAbsoluteHostPath('relative/app')).toBe(false); + }); + + it('extracts project names with either separator', () => { + expect(hostBasename('C:\\My Projects\\app\\')).toBe('app'); + expect(hostBasename('/home/me/app/')).toBe('app'); + }); +}); diff --git a/src/lib/host-path.ts b/src/lib/host-path.ts new file mode 100644 index 000000000..2525cb908 --- /dev/null +++ b/src/lib/host-path.ts @@ -0,0 +1,9 @@ +/** Host filesystem paths arrive over IPC; keep this browser-safe. */ +export function hostBasename(value: string): string { + const parts = value.replace(/[\\/]+$/, '').split(/[\\/]/); + return parts[parts.length - 1] || value; +} + +export function isAbsoluteHostPath(value: string): boolean { + return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\'); +} diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 2187a5422..8d8b237d7 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -1,5 +1,6 @@ import { produce } from 'solid-js/store'; import { invoke } from '../lib/ipc'; +import { hostBasename } from '../lib/host-path'; import { IPC } from '../../electron/ipc/channels'; import { store, setStore } from './core'; import { startRemoteAccess } from './remote'; @@ -516,8 +517,7 @@ export async function loadState(): Promise { } if (projects.length === 0 && raw.projectRoot) { - const segments = raw.projectRoot.split('/'); - const name = segments[segments.length - 1] || raw.projectRoot; + const name = hostBasename(raw.projectRoot); const id = crypto.randomUUID(); projects = [{ id, name, path: raw.projectRoot, color: randomPastelColor() }]; lastProjectId = id; diff --git a/src/store/projects.ts b/src/store/projects.ts index 4ef8f7d86..7ff9bfc5c 100644 --- a/src/store/projects.ts +++ b/src/store/projects.ts @@ -1,5 +1,6 @@ import { produce } from 'solid-js/store'; import { openDialog } from '../lib/dialog'; +import { hostBasename } from '../lib/host-path'; import { invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; import { store, setStore } from './core'; @@ -190,8 +191,7 @@ export async function pickAndAddProject(): Promise { const isGitRepo = await invoke(IPC.CheckIsGitRepo, { path }); - const segments = path.split('/'); - const name = segments[segments.length - 1] || path; + const name = hostBasename(path); return addProject(name, path, isGitRepo); } diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 1f8091932..0834f2a29 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -3,6 +3,8 @@ import { invoke, Channel } from '../lib/ipc'; import { asStoreVerificationRun } from '../lib/verification-run'; import { IPC } from '../../electron/ipc/channels'; import { getSkipPermissionsArgs } from '../../electron/shared/skip-permissions'; +import { commandName } from '../../electron/shared/command-name'; +import { hostBasename } from '../lib/host-path'; import { store, setStore, cleanupPanelEntries } from './core'; import { effectiveAgentId } from './agent-select'; import { saveState } from './persistence'; @@ -417,7 +419,7 @@ function deriveImportedTaskName(branchName: string, worktreePath: string): strin const branchTail = branchName.split('/').pop()?.trim() ?? ''; const normalized = branchTail.replace(/[-_]+/g, ' ').trim(); if (normalized) return cleanTaskName(normalized); - return worktreePath.split('/').pop()?.trim() || branchName; + return hostBasename(worktreePath).trim() || branchName; } function hasTaskForWorktreePath(worktreePath: string): boolean { @@ -1072,7 +1074,7 @@ export function uncollapseTask(taskId: string): void { function matchProject(repoName: string): string | null { const lower = repoName.toLowerCase(); for (const project of store.projects) { - const basename = project.path.split('/').pop() ?? ''; + const basename = hostBasename(project.path); if (basename.toLowerCase() === lower) return project.id; } return null; @@ -1408,11 +1410,11 @@ export function setTaskMcpLaunchArgs(taskId: string, args: string[] | undefined) } function isCodexCommand(command: string | undefined): boolean { - return command?.split('/').pop()?.includes('codex') === true; + return command ? commandName(command).includes('codex') : false; } function isAntigravityCommand(command: string | undefined): boolean { - return command?.split('/').pop() === 'agy'; + return command ? commandName(command) === 'agy' : false; } function taskRequiresMcpLaunchArgs(taskId: string): boolean {