diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9e8f32e..d62c4c22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,9 @@ importers: remark-math: specifier: ^6.0.0 version: 6.0.0 + shell-quote: + specifier: ^1.8.3 + version: 1.8.4 sqlite: specifier: ^5.1.1 version: 5.1.1 diff --git a/src/cli/pilotdeck.ts b/src/cli/pilotdeck.ts index f8f7fa70..8c6d4226 100644 --- a/src/cli/pilotdeck.ts +++ b/src/cli/pilotdeck.ts @@ -139,12 +139,12 @@ async function main(argv = process.argv.slice(2)): Promise { let reloadChain = Promise.resolve(); configStore.subscribe((event) => { - if (event.changedPaths.some((p) => p.startsWith("telemetry."))) { + if (event.changedPaths.some((p) => p.startsWith("telemetry.") || p === "telemetry")) { telemetry.setEnabled(event.nextSnapshot.config.telemetry?.enabled ?? false); } - const aoChanged = event.changedPaths.some((p) => p.startsWith("alwaysOn.")); - const cronChanged = event.changedPaths.some((p) => p.startsWith("cron.")); + const aoChanged = event.changedPaths.some((p) => p.startsWith("alwaysOn.") || p === "alwaysOn"); + const cronChanged = event.changedPaths.some((p) => p.startsWith("cron.") || p === "cron"); const proxyChanged = event.changedPaths.some((p) => p.startsWith("proxy.") || p === "proxy"); const adapterChanged = event.changedPaths.some((p) => p.startsWith("adapters.")); diff --git a/src/context/memory/edgeclaw-memory-core/package.json b/src/context/memory/edgeclaw-memory-core/package.json index aff43d82..cfeebfaa 100644 --- a/src/context/memory/edgeclaw-memory-core/package.json +++ b/src/context/memory/edgeclaw-memory-core/package.json @@ -12,7 +12,7 @@ } }, "scripts": { - "build": "rm -rf lib && tsc -p tsconfig.json", + "build": "node -e \"require('fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { diff --git a/src/mcp/config/loadMcpServerConfig.ts b/src/mcp/config/loadMcpServerConfig.ts index 0c64c4e0..4bf168d7 100644 --- a/src/mcp/config/loadMcpServerConfig.ts +++ b/src/mcp/config/loadMcpServerConfig.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; import { resolve } from "node:path"; export const MCP_CONFIG_FILE_NAME = "mcp.json"; @@ -82,7 +83,7 @@ function expandConfig(value: unknown): unknown { function expandString(value: string): string { return value .replace(/\$\{env:([^}]+)\}/g, (_match, name: string) => process.env[name] ?? "") - .replace(/\$\{userHome\}/g, process.env.HOME ?? ""); + .replace(/\$\{userHome\}/g, process.env.HOME ?? process.env.USERPROFILE ?? homedir()); } function isRecord(value: unknown): value is Record { diff --git a/src/model/streaming/streamModel.ts b/src/model/streaming/streamModel.ts index 09bbc2e7..ac389a61 100644 --- a/src/model/streaming/streamModel.ts +++ b/src/model/streaming/streamModel.ts @@ -95,7 +95,9 @@ export async function* streamModel( const body = buildModelRequest(currentRequest, config); if (process.env.PILOTDECK_DUMP_REQUEST === "1") { const fs = await import("node:fs"); - const dumpPath = `/tmp/pilotdeck_request_${Date.now()}.json`; + const os = await import("node:os"); + const path = await import("node:path"); + const dumpPath = path.join(os.tmpdir(), `pilotdeck_request_${Date.now()}.json`); fs.writeFileSync(dumpPath, JSON.stringify(body, null, 2)); console.log(`[model-debug] Request dumped to ${dumpPath} (model=${currentRequest.model})`); } diff --git a/src/pilot/paths.ts b/src/pilot/paths.ts index fc7cdf15..19543737 100644 --- a/src/pilot/paths.ts +++ b/src/pilot/paths.ts @@ -120,7 +120,7 @@ function findStoredProjectId(projectRoot: string, pilotHome: string): string | n if (!existsSync(projectsDir)) { return null; } - const target = resolve(projectRoot); + const target = normalizeProjectPathForMarkerComparison(projectRoot); try { for (const entry of readdirSync(projectsDir, { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -133,7 +133,7 @@ function findStoredProjectId(projectRoot: string, pilotHome: string): string | n } catch { continue; } - if (!marker || resolve(marker) !== target) { + if (!marker || normalizeProjectPathForMarkerComparison(marker) !== target) { continue; } try { @@ -149,3 +149,8 @@ function findStoredProjectId(projectRoot: string, pilotHome: string): string | n } return null; } + +function normalizeProjectPathForMarkerComparison(projectRoot: string): string { + const resolved = resolve(projectRoot); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} diff --git a/src/session/storage/SessionList.ts b/src/session/storage/SessionList.ts index 3f4a4ff7..331bd87c 100644 --- a/src/session/storage/SessionList.ts +++ b/src/session/storage/SessionList.ts @@ -3,14 +3,10 @@ import { join, resolve } from "node:path"; import { getPilotProjectChatDir } from "../../pilot/index.js"; import { readSessionLite, type SessionLiteFile } from "./SessionLiteReader.js"; -const ALWAYS_ON_AUXILIARY_PREFIXES = [ - "always-on-discovery:", - "always-on-workspace:", - "always-on-report:", -]; +const ALWAYS_ON_AUXILIARY_PATTERN = /^always-on-(discovery|workspace|report)[:\-]/; function isInternalSession(sessionId: string): boolean { - return ALWAYS_ON_AUXILIARY_PREFIXES.some((p) => sessionId.startsWith(p)); + return ALWAYS_ON_AUXILIARY_PATTERN.test(sessionId); } export type SessionInfo = { diff --git a/src/tool/builtin/bash.ts b/src/tool/builtin/bash.ts index 238ae6fe..d1ecb549 100644 --- a/src/tool/builtin/bash.ts +++ b/src/tool/builtin/bash.ts @@ -18,7 +18,7 @@ export type CreateBashToolOptions = { const BASH_TOOL_DESCRIPTION = `Run a shell command in the PilotDeck workspace. Usage: -- The \`command\` parameter is passed to \`/bin/sh -c\`. +- The \`command\` parameter is passed to the system shell (\`cmd.exe\` on Windows, \`/bin/sh\` on macOS/Linux). - The shell runs in the current workspace directory and inherits the tool runtime environment. - Use \`timeout\` to override the command timeout in milliseconds. When omitted, the default is 30000ms. Values above 600000ms are clamped to the maximum. - Use \`description\` to provide a short, clear label for logs and audits. Prefer 3-10 words that say what the command does. @@ -43,7 +43,7 @@ export function createBashTool(options?: CreateBashToolOptions): PilotDeckToolDe properties: { command: { type: "string", - description: "The shell command to execute (passed to /bin/sh -c).", + description: "The shell command to execute (passed to the system shell).", }, timeout: { type: "integer", diff --git a/src/tool/builtin/bash/commandRunner.ts b/src/tool/builtin/bash/commandRunner.ts index 2b8134e6..29bb6abf 100644 --- a/src/tool/builtin/bash/commandRunner.ts +++ b/src/tool/builtin/bash/commandRunner.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { TextDecoder } from "node:util"; export type PilotDeckCommandOptions = { cwd: string; @@ -23,15 +24,21 @@ export type PilotDeckCommandRunner = { run(command: string, options: PilotDeckCommandOptions): Promise; }; +type SpawnShell = typeof spawn; + export class NodeShellCommandRunner implements PilotDeckCommandRunner { + constructor(private readonly spawnShell: SpawnShell = spawn) {} + run(command: string, options: PilotDeckCommandOptions): Promise { const startedAt = Date.now(); return new Promise((resolve, reject) => { - const child = spawn(command, { + const isWindows = process.platform === "win32"; + const child = this.spawnShell(command, { cwd: options.cwd, env: options.env, shell: true, - detached: true, + detached: !isWindows, + windowsHide: isWindows, stdio: ["ignore", "pipe", "pipe"], }); @@ -44,10 +51,14 @@ export class NodeShellCommandRunner implements PilotDeckCommandRunner { const pid = child.pid; if (!pid) return; if (process.platform === "win32") { - try { child.kill("SIGTERM"); } catch { /* already dead */ } - setTimeout(() => { - try { child.kill("SIGKILL"); } catch { /* already dead */ } - }, 3000).unref(); + try { + const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }); + killer.on("error", () => undefined); + killer.unref(); + } catch { /* best-effort */ } } else { try { process.kill(-pid, "SIGTERM"); } catch { /* already dead */ } setTimeout(() => { @@ -59,11 +70,29 @@ export class NodeShellCommandRunner implements PilotDeckCommandRunner { const timeout = setTimeout(() => { timedOut = true; killProcessGroup(); + forceResolveAfterKill(); }, options.timeoutMs); + const ABORT_FORCE_RESOLVE_MS = 15_000; + + function forceResolveAfterKill() { + setTimeout(() => { + if (settled) return; + cleanup(); + resolve({ + exitCode: null, + stdout, + stderr: stderr + "\n[PilotDeck] Process did not exit within 15s after termination; force-resolved.", + timedOut: true, + durationMs: Date.now() - startedAt, + }); + }, ABORT_FORCE_RESOLVE_MS).unref(); + } + const onAbort = () => { if (settled) return; killProcessGroup(); + forceResolveAfterKill(); }; options.signal?.addEventListener("abort", onAbort, { once: true }); @@ -73,29 +102,52 @@ export class NodeShellCommandRunner implements PilotDeckCommandRunner { options.signal?.removeEventListener("abort", onAbort); } - child.stdout?.setEncoding("utf8"); - child.stderr?.setEncoding("utf8"); - child.stdout?.on("data", (chunk: string) => { - stdout += chunk; + const stdoutDecoder = createShellOutputDecoder(); + const stderrDecoder = createShellOutputDecoder(); + let closeFallback: ReturnType | undefined; + + function finish(exitCode: number | null) { + if (closeFallback) { + clearTimeout(closeFallback); + closeFallback = undefined; + } + stdout += stdoutDecoder.flush(); + stderr += stderrDecoder.flush(); + cleanup(); + resolve({ + exitCode, + stdout, + stderr, + timedOut, + durationMs: Date.now() - startedAt, + }); + } + + child.stdout?.on("data", (chunk: Buffer) => { + const text = stdoutDecoder.decode(chunk); + stdout += text; if (options.onStdout) { try { - options.onStdout(chunk); + options.onStdout(text); } catch { // Progress callbacks are fire-and-forget; never crash the runner. } } }); - child.stderr?.on("data", (chunk: string) => { - stderr += chunk; + child.stderr?.on("data", (chunk: Buffer) => { + const text = stderrDecoder.decode(chunk); + stderr += text; if (options.onStderr) { try { - options.onStderr(chunk); + options.onStderr(text); } catch { // Progress callbacks are fire-and-forget; never crash the runner. } } }); child.on("error", (error) => { + stdout += stdoutDecoder.flush(); + stderr += stderrDecoder.flush(); cleanup(); if (options.signal?.aborted) { resolve({ @@ -109,16 +161,151 @@ export class NodeShellCommandRunner implements PilotDeckCommandRunner { } reject(error); }); + child.on("exit", (exitCode) => { + if (process.platform !== "win32" || settled || closeFallback) { + return; + } + closeFallback = setTimeout(() => { + if (settled) return; + finish(exitCode); + }, 250); + closeFallback.unref(); + }); child.on("close", (exitCode) => { - cleanup(); - resolve({ - exitCode, - stdout, - stderr, - timedOut, - durationMs: Date.now() - startedAt, - }); + finish(exitCode); }); }); } } + +export type ShellOutputDecoder = { + decode(chunk: Buffer): string; + flush(): string; +}; + +export function createShellOutputDecoder(): ShellOutputDecoder { + if (process.platform !== "win32") { + const decoder = new TextDecoder("utf-8"); + return { + decode: (chunk) => decoder.decode(chunk, { stream: true }), + flush: () => decoder.decode(), + }; + } + + return createWindowsShellOutputDecoder(); +} + +export function decodeShellOutput(chunk: Buffer): string { + if (process.platform !== "win32") { + return chunk.toString("utf8"); + } + const decoder = createWindowsShellOutputDecoder(); + return decoder.decode(chunk) + decoder.flush(); +} + +function createWindowsShellOutputDecoder(): ShellOutputDecoder { + let mode: "unknown" | "utf8" | "gb18030" = "unknown"; + let pending = Buffer.alloc(0); + const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + let gb18030Decoder: TextDecoder | undefined; + + return { + decode: (chunk) => { + if (mode === "utf8") { + return utf8Decoder.decode(chunk, { stream: true }); + } + if (mode === "gb18030") { + gb18030Decoder ??= new TextDecoder("gb18030"); + return gb18030Decoder.decode(chunk, { stream: true }); + } + + pending = pending.length > 0 ? Buffer.concat([pending, chunk]) : Buffer.from(chunk); + if (!hasNonAsciiByte(pending)) { + const text = pending.toString("utf8"); + pending = Buffer.alloc(0); + return text; + } + + const utf8Status = inspectUtf8(pending); + if (utf8Status === "incomplete") { + return ""; + } + if (utf8Status === "valid") { + mode = "utf8"; + const text = utf8Decoder.decode(pending, { stream: true }); + pending = Buffer.alloc(0); + return text; + } + + mode = "gb18030"; + gb18030Decoder = new TextDecoder("gb18030"); + const text = gb18030Decoder.decode(pending, { stream: true }); + pending = Buffer.alloc(0); + return text; + }, + flush: () => { + if (mode === "utf8") { + return utf8Decoder.decode(); + } + if (mode === "gb18030") { + return gb18030Decoder?.decode() ?? ""; + } + const text = pending.toString("utf8"); + pending = Buffer.alloc(0); + return text; + }, + }; +} + +function hasNonAsciiByte(chunk: Buffer): boolean { + return chunk.some((byte) => byte >= 0x80); +} + +function inspectUtf8(chunk: Buffer): "valid" | "incomplete" | "invalid" { + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]!; + if (byte <= 0x7f) continue; + + let expectedContinuation = 0; + let minCodePoint = 0; + let codePoint = 0; + if (byte >= 0xc2 && byte <= 0xdf) { + expectedContinuation = 1; + minCodePoint = 0x80; + codePoint = byte & 0x1f; + } else if (byte >= 0xe0 && byte <= 0xef) { + expectedContinuation = 2; + minCodePoint = 0x800; + codePoint = byte & 0x0f; + } else if (byte >= 0xf0 && byte <= 0xf4) { + expectedContinuation = 3; + minCodePoint = 0x10000; + codePoint = byte & 0x07; + } else { + return "invalid"; + } + + if (i + expectedContinuation >= chunk.length) { + return "incomplete"; + } + + for (let offset = 1; offset <= expectedContinuation; offset += 1) { + const continuation = chunk[i + offset]!; + if ((continuation & 0xc0) !== 0x80) { + return "invalid"; + } + codePoint = (codePoint << 6) | (continuation & 0x3f); + } + + if ( + codePoint < minCodePoint || + codePoint > 0x10ffff || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ) { + return "invalid"; + } + + i += expectedContinuation; + } + return "valid"; +} diff --git a/src/tool/builtin/bash/permissions.ts b/src/tool/builtin/bash/permissions.ts index 07b64491..3981cff1 100644 --- a/src/tool/builtin/bash/permissions.ts +++ b/src/tool/builtin/bash/permissions.ts @@ -46,13 +46,16 @@ const SIMPLE_READ_COMMANDS = new Set([ const WINDOWS_READ_COMMANDS = new Set([ "dir", + "findstr", "get-childitem", + "get-command", "get-content", "get-date", "get-item", "get-itemproperty", "get-location", "get-process", + "resolve-path", "select-string", "test-path", "type", @@ -98,13 +101,13 @@ export function isReadOnlyShellCommand(command: string): boolean { } const [commandName, ...args] = tokens; - const normalizedCommandName = commandName.toLowerCase(); + const normalizedCommandName = normalizeExecutableName(commandName); if (SIMPLE_READ_COMMANDS.has(normalizedCommandName) || WINDOWS_READ_COMMANDS.has(normalizedCommandName)) { return true; } if (normalizedCommandName === "git") { - const subcommand = args.find((arg) => !arg.startsWith("-")); + const subcommand = getGitSubcommand(args); return ( subcommand !== undefined && READ_ONLY_GIT_SUBCOMMANDS.has(subcommand) @@ -112,6 +115,10 @@ export function isReadOnlyShellCommand(command: string): boolean { ); } + if (isPowerShellCommand(normalizedCommandName)) { + return isReadOnlyPowerShellInvocation(args); + } + if (normalizedCommandName === "find") { return isReadOnlyFindTokens(args); } @@ -119,6 +126,102 @@ export function isReadOnlyShellCommand(command: string): boolean { return normalizedCommandName === "sh" && args.length === 2 && args[0] === "-c" && /^exit\s+\d+$/.test(args[1]); } +const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([ + "--namespace", + "--super-prefix", +]); + +const GIT_GLOBAL_OPTIONS_WITH_VALUE_PREFIXES = [ + "--namespace=", + "--super-prefix=", +]; + +const GIT_UNSAFE_GLOBAL_OPTIONS_WITH_VALUE = new Set([ + "-C", + "-c", + "--config-env", + "--exec-path", + "--git-dir", + "--work-tree", +]); +const GIT_UNSAFE_GLOBAL_OPTIONS_WITH_VALUE_PREFIXES = [ + "-C", + "-c", + "--config-env=", + "--exec-path=", + "--git-dir=", + "--work-tree=", +]; + +function getGitSubcommand(args: string[]): string | undefined { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === "--") { + return undefined; + } + if ( + GIT_UNSAFE_GLOBAL_OPTIONS_WITH_VALUE.has(arg) + || GIT_UNSAFE_GLOBAL_OPTIONS_WITH_VALUE_PREFIXES.some((prefix) => arg.startsWith(prefix)) + ) { + return undefined; + } + if (GIT_GLOBAL_OPTIONS_WITH_VALUE.has(arg)) { + index += 1; + continue; + } + if (GIT_GLOBAL_OPTIONS_WITH_VALUE_PREFIXES.some((prefix) => arg.startsWith(prefix))) { + continue; + } + if (arg.startsWith("-")) { + continue; + } + return arg.toLowerCase(); + } + return undefined; +} + +function isPowerShellCommand(commandName: string): boolean { + return commandName === "powershell" || commandName === "pwsh"; +} + +function isReadOnlyPowerShellInvocation(args: string[]): boolean { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + const normalized = arg.toLowerCase(); + if (normalized === "-noprofile" || normalized === "-noninteractive" || normalized === "-nologo") { + continue; + } + if (normalized === "-command" || normalized === "-c") { + return isReadOnlyPowerShellCommand(args.slice(index + 1)); + } + if (normalized.startsWith("-command:")) { + return isReadOnlyPowerShellCommand([arg.slice("-command:".length)]); + } + return false; + } + return false; +} + +function isReadOnlyPowerShellCommand(commandTokens: string[]): boolean { + if (commandTokens.length === 0) { + return false; + } + const commandText = commandTokens.join(" "); + if (/[{}|;&<>`]/.test(commandText) || /\$\s*\(/.test(commandText)) { + return false; + } + const tokens = tokenizeSimpleShell(commandText); + if (!tokens || tokens.length === 0) { + return false; + } + const commandName = normalizeExecutableName(tokens[0]!); + return SIMPLE_READ_COMMANDS.has(commandName) || WINDOWS_READ_COMMANDS.has(commandName); +} + +function normalizeExecutableName(commandName: string): string { + return commandName.toLowerCase().replace(/\.(exe|cmd|bat)$/i, ""); +} + const FIND_MUTATING_OR_EXEC_ACTIONS = new Set([ "-delete", "-exec", diff --git a/ui/package.json b/ui/package.json index 0373ff73..e8a55d42 100644 --- a/ui/package.json +++ b/ui/package.json @@ -85,6 +85,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", + "shell-quote": "^1.8.3", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "streamdown": "^2.5.0", diff --git a/ui/server/index.js b/ui/server/index.js index eefcef2c..9cfc67fd 100755 --- a/ui/server/index.js +++ b/ui/server/index.js @@ -47,12 +47,13 @@ import os from 'os'; import http from 'http'; import cors from 'cors'; import { promises as fsPromises } from 'fs'; -import { spawn, exec } from 'child_process'; +import { spawn } from 'child_process'; import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; import JSZip from 'jszip'; import { readPermissionSettings } from './services/permissionSettings.js'; +import { getOpenUrlSpawnCommand } from './utils/processSpawn.js'; import { getProjects, getProjectCronJobsOverview, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; import { @@ -2950,10 +2951,14 @@ async function startServer() { || process.env.PILOTDECK_SKIP_BROWSER_OPEN === '1'; if (!skipAutoOpen) { const serverUrl = `http://${DISPLAY_HOST === '0.0.0.0' ? 'localhost' : DISPLAY_HOST}:${boundPort}`; - const openCmd = process.platform === 'darwin' ? 'open' - : process.platform === 'win32' ? 'start' - : 'xdg-open'; - exec(`${openCmd} "${serverUrl}"`, () => {}); + const { command, args } = getOpenUrlSpawnCommand(serverUrl); + const opener = spawn(command, args, { + stdio: 'ignore', + detached: process.platform !== 'win32', + windowsHide: process.platform === 'win32', + }); + opener.on('error', () => {}); + opener.unref(); } // Start watching the projects folder for changes diff --git a/ui/server/routes/agent.js b/ui/server/routes/agent.js index 99f40888..dcc53c76 100644 --- a/ui/server/routes/agent.js +++ b/ui/server/routes/agent.js @@ -66,7 +66,8 @@ async function getGitRemoteUrl(repoPath) { return new Promise((resolve, reject) => { const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], { cwd: repoPath, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32' }); let stdout = ''; @@ -224,7 +225,8 @@ async function getCommitMessages(projectPath, limit = 5) { return new Promise((resolve, reject) => { const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], { cwd: projectPath, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32' }); let stdout = ''; @@ -375,7 +377,8 @@ async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) { // Execute git clone const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], { - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32' }); let stdout = ''; @@ -1000,7 +1003,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { console.log('🔄 Creating local branch...'); const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], { cwd: finalProjectPath, - stdio: 'pipe' + stdio: 'pipe', + windowsHide: process.platform === 'win32' }); await new Promise((resolve, reject) => { @@ -1016,7 +1020,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { console.log(`ℹ️ Branch '${finalBranchName}' already exists locally, checking out...`); const checkoutExisting = spawn('git', ['checkout', finalBranchName], { cwd: finalProjectPath, - stdio: 'pipe' + stdio: 'pipe', + windowsHide: process.platform === 'win32' }); checkoutExisting.on('close', (checkoutCode) => { if (checkoutCode === 0) { @@ -1037,7 +1042,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { console.log('🔄 Pushing branch to remote...'); const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], { cwd: finalProjectPath, - stdio: 'pipe' + stdio: 'pipe', + windowsHide: process.platform === 'win32' }); await new Promise((resolve, reject) => { diff --git a/ui/server/routes/config.js b/ui/server/routes/config.js index 3bec8565..d03aa969 100644 --- a/ui/server/routes/config.js +++ b/ui/server/routes/config.js @@ -2,6 +2,7 @@ import express from 'express'; import fsPromises from 'fs/promises'; import path from 'path'; import { spawn } from 'child_process'; +import { prepareBackgroundSpawnOptions } from '../utils/processSpawn.js'; import { parse as parseYaml } from 'yaml'; import { buildDefaultPilotDeckConfig, @@ -467,7 +468,7 @@ router.post('/open', async (_req, res) => { : process.platform === 'win32' ? ['/c', 'start', '', configPath] : [path.dirname(configPath)]; - const child = spawn(command, args, { stdio: 'ignore', detached: true }); + const child = spawn(command, args, prepareBackgroundSpawnOptions({ stdio: 'ignore', detached: true })); child.unref(); res.json({ success: true, path: configPath }); } catch (error) { diff --git a/ui/server/routes/git.js b/ui/server/routes/git.js index 7a38716e..c670545c 100755 --- a/ui/server/routes/git.js +++ b/ui/server/routes/git.js @@ -13,6 +13,7 @@ function spawnAsync(command, args, options = {}) { const child = spawn(command, args, { ...options, shell: false, + windowsHide: process.platform === 'win32', }); let stdout = ''; diff --git a/ui/server/routes/mcp.js b/ui/server/routes/mcp.js index 0ece33b4..8bb401a9 100644 --- a/ui/server/routes/mcp.js +++ b/ui/server/routes/mcp.js @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { spawn } from 'child_process'; import { getPilotDeckGateway } from '../pilotdeck-bridge.js'; +import { prepareCliSpawn } from '../utils/processSpawn.js'; import { listMcpConfigFiles, readMcpConfigFile, @@ -17,6 +18,11 @@ const router = express.Router(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +function spawnCli(command, args, options = {}) { + const prepared = prepareCliSpawn(command, args, options); + return spawn(prepared.command, prepared.args, prepared.options); +} + router.get('/config', async (req, res) => { try { const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : undefined; @@ -84,11 +90,7 @@ router.get('/cli/list', async (req, res) => { try { console.log('📋 Listing MCP servers using Claude CLI'); - const { spawn } = await import('child_process'); - const { promisify } = await import('util'); - const exec = promisify(spawn); - - const process = spawn('claude', ['mcp', 'list'], { + const process = spawnCli('claude', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] }); @@ -130,8 +132,6 @@ router.post('/cli/add', async (req, res) => { console.log(`➕ Adding MCP server using Claude CLI (${scope} scope):`, name); - const { spawn } = await import('child_process'); - let cliArgs = ['mcp', 'add']; // Add scope flag @@ -173,7 +173,7 @@ router.post('/cli/add', async (req, res) => { console.log('📁 Running in project directory:', projectPath); } - const process = spawn('claude', cliArgs, spawnOptions); + const process = spawnCli('claude', cliArgs, spawnOptions); let stdout = ''; let stderr = ''; @@ -247,8 +247,6 @@ router.post('/cli/add-json', async (req, res) => { }); } - const { spawn } = await import('child_process'); - const cliArgs = ['mcp', 'add-json', '--scope', scope, name]; // Add the JSON config as a properly formatted string @@ -267,7 +265,7 @@ router.post('/cli/add-json', async (req, res) => { console.log('📁 Running in project directory:', projectPath); } - const process = spawn('claude', cliArgs, spawnOptions); + const process = spawnCli('claude', cliArgs, spawnOptions); let stdout = ''; let stderr = ''; @@ -319,8 +317,6 @@ router.delete('/cli/remove/:name', async (req, res) => { console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope); - const { spawn } = await import('child_process'); - // Build command args based on scope let cliArgs = ['mcp', 'remove']; @@ -336,7 +332,7 @@ router.delete('/cli/remove/:name', async (req, res) => { console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' ')); - const process = spawn('claude', cliArgs, { + const process = spawnCli('claude', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); @@ -378,9 +374,7 @@ router.get('/cli/get/:name', async (req, res) => { console.log('📄 Getting MCP server details using Claude CLI:', name); - const { spawn } = await import('child_process'); - - const process = spawn('claude', ['mcp', 'get', name], { + const process = spawnCli('claude', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] }); @@ -618,4 +612,4 @@ function parseClaudeGetOutput(output) { } } -export default router; \ No newline at end of file +export default router; diff --git a/ui/server/routes/projects.js b/ui/server/routes/projects.js index c60e29fa..20d8826b 100644 --- a/ui/server/routes/projects.js +++ b/ui/server/routes/projects.js @@ -558,7 +558,8 @@ router.get('/clone-progress', async (req, res) => { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' - } + }, + windowsHide: process.platform === 'win32' }); let lastError = ''; @@ -650,7 +651,8 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' - } + }, + windowsHide: process.platform === 'win32' }); let stdout = ''; diff --git a/ui/server/routes/taskmaster.js b/ui/server/routes/taskmaster.js index 1f1a20f9..fe376971 100644 --- a/ui/server/routes/taskmaster.js +++ b/ui/server/routes/taskmaster.js @@ -19,12 +19,18 @@ import os from 'os'; import { extractProjectDirectory } from '../projects.js'; import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js'; import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js'; +import { prepareCliSpawn } from '../utils/processSpawn.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const router = express.Router(); +function spawnCli(command, args, options = {}) { + const prepared = prepareCliSpawn(command, args, options); + return spawn(prepared.command, prepared.args, prepared.options); +} + /** * Check if TaskMaster CLI is installed globally * @returns {Promise} Installation status result @@ -32,7 +38,7 @@ const router = express.Router(); async function checkTaskMasterInstallation() { return new Promise((resolve) => { // Check if task-master command is available - const child = spawn('which', ['task-master'], { + const child = spawnCli('which', ['task-master'], { stdio: ['ignore', 'pipe', 'pipe'], shell: true }); @@ -51,7 +57,7 @@ async function checkTaskMasterInstallation() { child.on('close', (code) => { if (code === 0 && output.trim()) { // TaskMaster is installed, get version - const versionChild = spawn('task-master', ['--version'], { + const versionChild = spawnCli('task-master', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], shell: true }); @@ -474,9 +480,7 @@ router.get('/next/:projectName', async (req, res) => { // Try to execute task-master next command try { - const { spawn } = await import('child_process'); - - const nextTaskCommand = spawn('task-master', ['next'], { + const nextTaskCommand = spawnCli('task-master', ['next'], { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'], shell: true @@ -997,7 +1001,7 @@ router.post('/init/:projectName', async (req, res) => { } // Run taskmaster init command - const initProcess = spawn('npx', ['task-master', 'init'], { + const initProcess = spawnCli('npx', ['task-master', 'init'], { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'], shell: true @@ -1101,7 +1105,7 @@ router.post('/add-task/:projectName', async (req, res) => { } // Run task-master add-task command - const addTaskProcess = spawn('npx', args, { + const addTaskProcess = spawnCli('npx', args, { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'], shell: true @@ -1182,7 +1186,7 @@ router.put('/update-task/:projectName/:taskId', async (req, res) => { // If only updating status, use set-status command if (status && Object.keys(req.body).length === 1) { - const setStatusProcess = spawn('npx', ['task-master-ai', 'set-status', `--id=${taskId}`, `--status=${status}`], { + const setStatusProcess = spawnCli('npx', ['task-master-ai', 'set-status', `--id=${taskId}`, `--status=${status}`], { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'], shell: true @@ -1235,7 +1239,7 @@ router.put('/update-task/:projectName/:taskId', async (req, res) => { const prompt = `Update task with the following changes: ${updates.join(', ')}`; - const updateProcess = spawn('npx', ['task-master-ai', 'update-task', `--id=${taskId}`, `--prompt=${prompt}`], { + const updateProcess = spawnCli('npx', ['task-master-ai', 'update-task', `--id=${taskId}`, `--prompt=${prompt}`], { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'], shell: true @@ -1335,7 +1339,7 @@ router.post('/parse-prd/:projectName', async (req, res) => { args.push('--research'); // Use research for better PRD parsing // Run task-master parse-prd command - const parsePRDProcess = spawn('npx', args, { + const parsePRDProcess = spawnCli('npx', args, { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'], shell: true diff --git a/ui/server/routes/update.js b/ui/server/routes/update.js index 3a81ae6e..757e66ed 100644 --- a/ui/server/routes/update.js +++ b/ui/server/routes/update.js @@ -203,6 +203,7 @@ router.post('/apply', async (req, res) => { cwd: PROJECT_ROOT, env: { ...process.env, FORCE_COLOR: '0' }, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32', }); let exitCode = null; @@ -271,6 +272,7 @@ router.post('/restart', async (req, res) => { detached: true, stdio: 'ignore', env: { ...process.env }, + windowsHide: process.platform === 'win32', }); child.unref(); diff --git a/ui/server/routes/user.js b/ui/server/routes/user.js index 6fb8439c..88a9b523 100644 --- a/ui/server/routes/user.js +++ b/ui/server/routes/user.js @@ -38,7 +38,11 @@ function hasUsablePilotDeckConfig() { function spawnAsync(command, args, options = {}) { return new Promise((resolve, reject) => { - const child = spawn(command, args, { ...options, shell: false }); + const child = spawn(command, args, { + ...options, + shell: false, + windowsHide: process.platform === 'win32', + }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { stdout += data.toString(); }); diff --git a/ui/server/services/cron-daemon-owner.js b/ui/server/services/cron-daemon-owner.js index 586d9018..d66085f8 100644 --- a/ui/server/services/cron-daemon-owner.js +++ b/ui/server/services/cron-daemon-owner.js @@ -18,6 +18,9 @@ function getCronDaemonOwnerPath() { } function getCronDaemonSocketPath() { + if (process.platform === 'win32') { + return '\\\\.\\pipe\\pilotdeck-cron-daemon'; + } return path.join(getPilotDeckConfigHomeDir(), 'cron-daemon.sock'); } diff --git a/ui/server/services/cron-daemon-startup.js b/ui/server/services/cron-daemon-startup.js index 0851f201..a36877bd 100644 --- a/ui/server/services/cron-daemon-startup.js +++ b/ui/server/services/cron-daemon-startup.js @@ -4,6 +4,7 @@ import { mkdirSync } from 'fs'; import os from 'os'; import { spawn } from 'child_process'; import { sendCronDaemonRequest } from './cron-daemon-owner.js'; +import { prepareBackgroundSpawnOptions } from '../utils/processSpawn.js'; // Cron daemon entry point. The launcher script is discoverable on PATH // or supplied via PILOTDECK_CRON_DAEMON_BIN. Returning `null` falls back @@ -149,12 +150,12 @@ export function startCronDaemonDetached({ const stdio = fd === null ? 'ignore' : ['ignore', fd, fd]; let child; try { - child = spawnFn(command, args, { + child = spawnFn(command, args, prepareBackgroundSpawnOptions({ cwd: process.cwd(), env: buildCronDaemonEnv(), detached: true, - stdio - }); + stdio, + })); } catch (err) { console.warn(`[WARN] Cron daemon spawn failed: ${err.message}`); return null; diff --git a/ui/server/services/permissionSettings.js b/ui/server/services/permissionSettings.js index 0981e3fa..547c52b5 100644 --- a/ui/server/services/permissionSettings.js +++ b/ui/server/services/permissionSettings.js @@ -59,14 +59,20 @@ export function readPermissionSettings(env = process.env) { } export function writePermissionSettings(updates, env = process.env) { + const current = readPermissionSettings(env); const next = normalizePermissionSettings({ - ...readPermissionSettings(env), + ...current, ...(updates || {}), lastUpdated: new Date().toISOString(), }); const filePath = getPermissionSettingsPath(env); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + } catch (err) { + console.error(`[permissionSettings] Failed to write ${filePath}:`, err); + throw err; + } return next; } diff --git a/ui/server/utils/gitConfig.js b/ui/server/utils/gitConfig.js index 586933a0..7b3857ba 100644 --- a/ui/server/utils/gitConfig.js +++ b/ui/server/utils/gitConfig.js @@ -2,7 +2,10 @@ import { spawn } from 'child_process'; function spawnAsync(command, args) { return new Promise((resolve, reject) => { - const child = spawn(command, args, { shell: false }); + const child = spawn(command, args, { + shell: false, + windowsHide: process.platform === 'win32', + }); let stdout = ''; child.stdout.on('data', (data) => { stdout += data.toString(); }); child.on('error', (error) => { reject(error); }); diff --git a/ui/server/utils/globalChrome.js b/ui/server/utils/globalChrome.js index 0b30be55..d58839dc 100644 --- a/ui/server/utils/globalChrome.js +++ b/ui/server/utils/globalChrome.js @@ -56,14 +56,28 @@ function removeLock() { function findChromePath() { const platform = process.platform; - const candidates = - platform === 'darwin' - ? [ - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', - ] - : ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium']; + let candidates; + if (platform === 'darwin') { + candidates = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ]; + } else if (platform === 'win32') { + const programFiles = process.env.PROGRAMFILES || 'C:\\Program Files'; + const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)'; + const localAppData = process.env.LOCALAPPDATA || ''; + candidates = [ + join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'), + join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'), + join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'), + join(programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + join(programFilesX86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + join(programFiles, 'Chromium', 'Application', 'chrome.exe'), + ]; + } else { + candidates = ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium']; + } for (const c of candidates) { if (fs.existsSync(c)) return c; @@ -121,6 +135,7 @@ function launchChrome(executablePath, userDataDir) { ], { stdio: 'ignore', detached: true, + windowsHide: process.platform === 'win32', }); proc.unref(); proc.on('exit', () => { @@ -145,8 +160,20 @@ async function killCDPPort() { const caller = _caller(); let pidList = []; try { - const raw = execSync(`lsof -ti :${CDP_PORT} 2>/dev/null`, { encoding: 'utf8' }).trim(); - if (raw) pidList = raw.split('\n').map(Number).filter(Boolean); + if (process.platform === 'win32') { + const raw = execSync( + `netstat -ano | findstr "LISTENING" | findstr ":${CDP_PORT} "`, + { encoding: 'utf8', windowsHide: true }, + ).trim(); + for (const line of raw.split('\n')) { + const parts = line.trim().split(/\s+/); + const pid = Number(parts[parts.length - 1]); + if (pid && !pidList.includes(pid)) pidList.push(pid); + } + } else { + const raw = execSync(`lsof -ti :${CDP_PORT} 2>/dev/null`, { encoding: 'utf8' }).trim(); + if (raw) pidList = raw.split('\n').map(Number).filter(Boolean); + } } catch { /* ignore */ } if (pidList.length === 0) { @@ -156,8 +183,14 @@ async function killCDPPort() { console.warn(`[BROWSER ${_ts()}] killCDPPort: sending SIGTERM to pids=${JSON.stringify(pidList)} | caller: ${caller}`); - for (const pid of pidList) { - try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ } + if (process.platform === 'win32') { + for (const pid of pidList) { + try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore', windowsHide: true }); } catch { /* ignore */ } + } + } else { + for (const pid of pidList) { + try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ } + } } const deadline = Date.now() + CHROME_STOP_TIMEOUT_MS; @@ -169,9 +202,11 @@ async function killCDPPort() { await new Promise((r) => setTimeout(r, CHROME_STOP_POLL_MS)); } - console.warn(`[BROWSER ${_ts()}] killCDPPort: SIGTERM timeout, sending SIGKILL to pids=${JSON.stringify(pidList)}`); - for (const pid of pidList) { - try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } + if (process.platform !== 'win32') { + console.warn(`[BROWSER ${_ts()}] killCDPPort: SIGTERM timeout, sending SIGKILL to pids=${JSON.stringify(pidList)}`); + for (const pid of pidList) { + try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } + } } await new Promise((r) => setTimeout(r, 300)); chromeProcess = null; diff --git a/ui/server/utils/pilotPaths.js b/ui/server/utils/pilotPaths.js index 944a4ddf..8c24e448 100644 --- a/ui/server/utils/pilotPaths.js +++ b/ui/server/utils/pilotPaths.js @@ -106,7 +106,7 @@ function findStoredProjectId(projectRoot, pilotHome) { const projectsDir = resolve(pilotHome, 'projects'); if (!existsSync(projectsDir)) return null; - const target = resolve(projectRoot); + const target = normalizeProjectPathForMarkerComparison(projectRoot); try { for (const entry of readdirSync(projectsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; @@ -117,7 +117,7 @@ function findStoredProjectId(projectRoot, pilotHome) { } catch { continue; } - if (!marker || resolve(marker) !== target) continue; + if (!marker || normalizeProjectPathForMarkerComparison(marker) !== target) continue; try { if (statSync(marker).isDirectory()) return entry.name; } catch { @@ -129,3 +129,8 @@ function findStoredProjectId(projectRoot, pilotHome) { } return null; } + +function normalizeProjectPathForMarkerComparison(projectRoot) { + const resolved = resolve(projectRoot); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} diff --git a/ui/server/utils/plugin-loader.js b/ui/server/utils/plugin-loader.js index 9c8dab35..263917c7 100644 --- a/ui/server/utils/plugin-loader.js +++ b/ui/server/utils/plugin-loader.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import { spawn } from 'child_process'; +import { prepareCliSpawn } from './processSpawn.js'; const PLUGINS_DIR = path.join(os.homedir(), '.pilotdeck', 'plugins'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.pilotdeck', 'plugins.json'); @@ -106,11 +107,12 @@ function runBuildIfNeeded(dir, packageJsonPath, onSuccess, onError) { return onSuccess(); // Unreadable package.json — skip build } - const buildProcess = spawn('npm', ['run', 'build'], { + const buildSpawn = prepareCliSpawn('npm', ['run', 'build'], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], shell: true, }); + const buildProcess = spawn(buildSpawn.command, buildSpawn.args, buildSpawn.options); let stderr = ''; let settled = false; @@ -296,6 +298,7 @@ export function installPluginFromGit(url) { const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], { stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32', }); let stderr = ''; @@ -339,11 +342,12 @@ export function installPluginFromGit(url) { // --ignore-scripts prevents postinstall hooks from executing arbitrary code. const packageJsonPath = path.join(tempDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { - const npmProcess = spawn('npm', ['install', '--ignore-scripts'], { + const npmSpawn = prepareCliSpawn('npm', ['install', '--ignore-scripts'], { cwd: tempDir, stdio: ['ignore', 'pipe', 'pipe'], shell: true, }); + const npmProcess = spawn(npmSpawn.command, npmSpawn.args, npmSpawn.options); npmProcess.on('close', (npmCode) => { if (npmCode !== 0) { @@ -380,6 +384,7 @@ export function updatePluginFromGit(name) { const gitProcess = spawn('git', ['pull', '--ff-only', '--'], { cwd: pluginDir, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32', }); let stderr = ''; @@ -407,11 +412,12 @@ export function updatePluginFromGit(name) { // Re-run npm install if package.json exists const packageJsonPath = path.join(pluginDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { - const npmProcess = spawn('npm', ['install', '--ignore-scripts'], { + const npmSpawn = prepareCliSpawn('npm', ['install', '--ignore-scripts'], { cwd: pluginDir, stdio: ['ignore', 'pipe', 'pipe'], shell: true, }); + const npmProcess = spawn(npmSpawn.command, npmSpawn.args, npmSpawn.options); npmProcess.on('close', (npmCode) => { if (npmCode !== 0) { return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`)); diff --git a/ui/server/utils/plugin-process-manager.js b/ui/server/utils/plugin-process-manager.js index d5fa493e..914691d2 100644 --- a/ui/server/utils/plugin-process-manager.js +++ b/ui/server/utils/plugin-process-manager.js @@ -36,6 +36,7 @@ export function startPluginServer(name, pluginDir, serverEntry) { PLUGIN_NAME: name, }, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32', }); let resolved = false; diff --git a/ui/server/utils/processSpawn.js b/ui/server/utils/processSpawn.js new file mode 100644 index 00000000..81f9a391 --- /dev/null +++ b/ui/server/utils/processSpawn.js @@ -0,0 +1,84 @@ +const WINDOWS_CMD_SHIMS = new Set([ + 'claude', + 'npm', + 'npx', + 'task-master', + 'task-master-ai', +]); + +function isWindows(platform = process.platform) { + return platform === 'win32'; +} + +function hasWindowsExecutableExtension(command) { + return /\.(?:cmd|exe|bat|com)$/i.test(command); +} + +function isWindowsCommandScript(command) { + return /\.(?:cmd|bat)$/i.test(command); +} + +function quoteWindowsCmdArg(value) { + const text = String(value); + if (text.length === 0) return '""'; + return `"${text.replace(/(["^&|<>()%!])/g, '^$1')}"`; +} + +function buildWindowsCmdLine(command, args) { + return [command, ...args].map(quoteWindowsCmdArg).join(' '); +} + +export function resolveWindowsCliCommand(command, platform = process.platform) { + if (!isWindows(platform)) return command; + + const normalized = String(command).toLowerCase(); + if (normalized === 'which') return 'where.exe'; + if (WINDOWS_CMD_SHIMS.has(normalized) && !hasWindowsExecutableExtension(command)) { + return `${command}.cmd`; + } + return command; +} + +export function prepareCliSpawn(command, args = [], options = {}, platform = process.platform) { + const windows = isWindows(platform); + const resolvedCommand = resolveWindowsCliCommand(command, platform); + const windowsOptions = windows + ? { ...options, shell: false, windowsHide: true } + : { ...options, shell: options.shell }; + + if (windows && isWindowsCommandScript(resolvedCommand)) { + return { + command: 'cmd.exe', + args: ['/d', '/s', '/c', `"${buildWindowsCmdLine(resolvedCommand, args)}"`], + options: { + ...windowsOptions, + windowsVerbatimArguments: true, + }, + }; + } + + return { + command: resolvedCommand, + args, + options: windowsOptions, + }; +} + +export function prepareBackgroundSpawnOptions(options = {}, platform = process.platform) { + const windows = isWindows(platform); + return { + ...options, + detached: windows ? false : options.detached, + windowsHide: windows ? true : options.windowsHide, + }; +} + +export function getOpenUrlSpawnCommand(url, platform = process.platform) { + if (isWindows(platform)) { + return { command: 'explorer.exe', args: [url] }; + } + if (platform === 'darwin') { + return { command: 'open', args: [url] }; + } + return { command: 'xdg-open', args: [url] }; +} diff --git a/ui/src/components/chat/utils/chatStorage.ts b/ui/src/components/chat/utils/chatStorage.ts index 07025033..383c3d71 100644 --- a/ui/src/components/chat/utils/chatStorage.ts +++ b/ui/src/components/chat/utils/chatStorage.ts @@ -112,15 +112,23 @@ export async function savePilotDeckPermissionSettings( return next; } +function unionStringArrays(a: string[], b: string[]): string[] { + const set = new Set(a); + for (const item of b) set.add(item); + return [...set]; +} + function mergePermissionSettings(value: unknown): PilotDeckSettings { const current = getPilotDeckSettings(); const parsed = value && typeof value === 'object' ? value as Partial : {}; + const backendAllowed = Array.isArray(parsed.allowedTools) ? parsed.allowedTools : []; + const backendDisallowed = Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : []; return { ...current, ...parsed, - allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [], - disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [], - skipPermissions: Boolean(parsed.skipPermissions), + allowedTools: unionStringArrays(current.allowedTools, backendAllowed), + disallowedTools: unionStringArrays(current.disallowedTools, backendDisallowed), + skipPermissions: typeof parsed.skipPermissions === 'boolean' ? parsed.skipPermissions : current.skipPermissions, projectSortOrder: current.projectSortOrder || 'name', }; } diff --git a/ui/src/components/chat/view/subcomponents/MessageComponent.tsx b/ui/src/components/chat/view/subcomponents/MessageComponent.tsx index d644a203..7f083e54 100644 --- a/ui/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/ui/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FileText } from 'lucide-react'; +import { FileText, Search, Settings } from 'lucide-react'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import type { ChatMessage, @@ -75,6 +75,11 @@ function isRecoverableToolUseError(content: unknown): boolean { return !looksLikePermissionError; } +function isWebSearchError(toolName: string | undefined): boolean { + const name = (toolName ?? '').toLowerCase(); + return name === 'web_search' || name === 'websearch'; +} + function getAttachmentTypeLabel(name?: string, mimeType?: string): string { const ext = String(name || '').split('.').pop()?.toUpperCase(); if (ext && ext !== String(name || '').toUpperCase()) return ext; @@ -377,6 +382,38 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o message.toolResult.isError ? (
{(() => { + if (isWebSearchError(message.toolName) && message.toolResult?.errorCode === 'setup_required') { + return ( +
+
+ +
+
+ {t('toolUseError.webSearchNotConfigured.title', { defaultValue: 'Web Search Not Ready' })} +
+
+ {t('toolUseError.webSearchNotConfigured.description', { defaultValue: 'The search API key is missing or invalid. Please go to Settings -> Config -> Search to check your search provider and API key.' })} +
+ +
+
+
+ ); + } + const recoverableToolError = isRecoverableToolUseError(message.toolResult?.content); const renderedErrorContent = recoverableToolError ? cleanToolUseErrorContent(message.toolResult?.content) diff --git a/ui/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx b/ui/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx index e0fb4635..98d6d669 100644 --- a/ui/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx +++ b/ui/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import type { PendingPermissionRequest } from '../../types/types'; import { buildPilotDeckToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions'; import { getPilotDeckSettings } from '../../utils/chatStorage'; @@ -27,6 +28,8 @@ export default function PermissionRequestsBanner({ handleGrantToolPermission, onPlanExecutionApproved, }: PermissionRequestsBannerProps) { + const { t } = useTranslation('chat'); + if (!pendingPermissionRequests.length) { return null; } @@ -70,7 +73,7 @@ export default function PermissionRequestsBanner({ const permissionEntry = buildPilotDeckToolPermissionEntry(first.toolName, rawInput); const settings = getPilotDeckSettings(); const alreadyAllowed = permissionEntry ? settings.allowedTools.includes(permissionEntry) : false; - const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember'; + const rememberLabel = alreadyAllowed ? t('permissionBanner.allowSaved') : t('permissionBanner.allowRemember'); return (
- Permission required{requests.length > 1 ? ` (${requests.length})` : ''} + {requests.length > 1 + ? t('permissionBanner.titleCount', { count: requests.length }) + : t('permissionBanner.title')}
- Tool: {first.toolName} + {t('permissionBanner.tool')} {first.toolName}
{permissionEntry && (
- Allow rule: {permissionEntry} + {t('permissionBanner.allowRule')} {permissionEntry}
)}
@@ -96,7 +101,7 @@ export default function PermissionRequestsBanner({ {requests.length <= 1 && rawInput && (
- View tool input + {t('permissionBanner.viewToolInput')}
                   {rawInput}
@@ -107,7 +112,7 @@ export default function PermissionRequestsBanner({
             {requests.length > 1 && (
               
- View {requests.length} tool inputs + {t('permissionBanner.viewToolInputs', { count: requests.length })}
{requests.map((r) => { @@ -128,7 +133,7 @@ export default function PermissionRequestsBanner({ onClick={() => handlePermissionDecision(allIds, { allow: true })} className="inline-flex items-center gap-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700" > - Allow once + {t('permissionBanner.allowOnce')}
diff --git a/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx b/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx index 2c73d516..aeba7d33 100644 --- a/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx +++ b/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx @@ -3186,6 +3186,12 @@ export default function PilotDeckConfigTab({ projects = [] }: { projects?: Setti : isDirty ? t('pilotDeckConfig.status.unsavedChanges') : t('pilotDeckConfig.status.noUnsavedChanges'); + const revealFileLabel = + typeof navigator !== 'undefined' && /win/i.test(navigator.platform) + ? t('pilotDeckConfig.actions.revealFileWindows') + : typeof navigator !== 'undefined' && /mac/i.test(navigator.platform) + ? t('pilotDeckConfig.actions.revealFileMac') + : t('pilotDeckConfig.actions.revealFileGeneric'); if (loading) { return ( @@ -3257,7 +3263,7 @@ export default function PilotDeckConfigTab({ projects = [] }: { projects?: Setti