From 931d3ba81fa773f01d82cad35b271530a4528914 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 18:55:56 +0700 Subject: [PATCH 01/32] fix: log bootstrap errors to daemon.log and add layout debug logging - Log fatal errors during daemon bootstrap and startup to pino (daemon.log) instead of only writing to stderr, which is invisible on Windows desktop - Delay process.exit to let pino async streams flush - Make voice MCP bridge script resolution non-fatal (warn + disable instead of crashing the daemon) - Add PASEO_ELECTRON_FLAGS env var for passing Chromium flags on Linux - Add layout debug logging to AppContainer for diagnosing sidebar issues --- packages/app/src/app/_layout.tsx | 31 ++++++++++++++ packages/desktop/src/main.ts | 12 ++++++ packages/server/src/server/bootstrap.ts | 55 ++++++++++++++----------- packages/server/src/server/index.ts | 13 +++--- 4 files changed, 82 insertions(+), 29 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index b54dc9090..75de47889 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -369,10 +369,41 @@ function AppContainer({ const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); + const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const isCompactLayout = isCompactFormFactor(); const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; + useEffect(() => { + const bp = UnistylesRuntime.breakpoint; + const screenW = UnistylesRuntime.screen.width; + const screenH = UnistylesRuntime.screen.height; + const isElectron = getIsElectronRuntime(); + const windowW = Platform.OS === "web" ? window.innerWidth : undefined; + const windowH = Platform.OS === "web" ? window.innerHeight : undefined; + const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined; + const ua = Platform.OS === "web" ? navigator.userAgent : undefined; + + console.log( + "[layout-debug]", + JSON.stringify({ + breakpoint: bp, + isCompactLayout, + isElectron, + chromeEnabled, + isFocusModeEnabled, + agentListOpen, + sidebarWidth, + sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled, + unistylesScreen: { w: screenW, h: screenH }, + window: { w: windowW, h: windowH }, + devicePixelRatio: dpr, + userAgent: ua, + }), + ); + }, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]); + useKeyboardShortcuts({ enabled: chromeEnabled, isMobile: isCompactLayout, diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index d5e4106c5..ff4c3d655 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -37,6 +37,18 @@ const APP_SCHEME = "paseo"; const OPEN_PROJECT_EVENT = "paseo:event:open-project"; app.setName("Paseo"); +// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging +// rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). +// Must run before app.whenReady(). +const electronFlags = process.env.PASEO_ELECTRON_FLAGS?.trim(); +if (electronFlags) { + for (const token of electronFlags.split(/\s+/)) { + const [key, ...rest] = token.replace(/^--/, "").split("="); + app.commandLine.appendSwitch(key, rest.join("=") || undefined); + } + log.info("[electron-flags]", electronFlags); +} + let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ argv: process.argv, isDefaultApp: process.defaultApp, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e0d4f3890..ada95fea3 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -120,21 +120,28 @@ import { resolveVoiceMcpBridgeFromRuntime } from "./voice-mcp-bridge-command.js" type AgentMcpTransportMap = Map; -function resolveVoiceMcpBridgeCommand(logger: Logger): { command: string; baseArgs: string[] } { - const decision = resolveVoiceMcpBridgeFromRuntime({ - bootstrapModuleUrl: import.meta.url, - execPath: process.execPath, - explicitScriptPath: process.env.PASEO_MCP_STDIO_SOCKET_BRIDGE_SCRIPT, - }); - logger.info( - { - source: decision.source, - command: decision.resolved.command, - baseArgs: decision.resolved.baseArgs, - }, - "Resolved voice MCP bridge command", - ); - return decision.resolved; +function resolveVoiceMcpBridgeCommand( + logger: Logger, +): { command: string; baseArgs: string[] } | null { + try { + const decision = resolveVoiceMcpBridgeFromRuntime({ + bootstrapModuleUrl: import.meta.url, + execPath: process.execPath, + explicitScriptPath: process.env.PASEO_MCP_STDIO_SOCKET_BRIDGE_SCRIPT, + }); + logger.info( + { + source: decision.source, + command: decision.resolved.command, + baseArgs: decision.resolved.baseArgs, + }, + "Resolved voice MCP bridge command", + ); + return decision.resolved; + } catch (err) { + logger.warn({ err }, "Voice MCP bridge script not available — voice MCP via stdio disabled"); + return null; + } } export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig; @@ -586,14 +593,16 @@ export async function createPaseoDaemon( speechService, terminalManager, { - voiceAgentMcpStdio: { - command: voiceMcpBridgeCommand.command, - baseArgs: [...voiceMcpBridgeCommand.baseArgs], - env: { - ELECTRON_RUN_AS_NODE: "1", - PASEO_HOME: config.paseoHome, - }, - }, + voiceAgentMcpStdio: voiceMcpBridgeCommand + ? { + command: voiceMcpBridgeCommand.command, + baseArgs: [...voiceMcpBridgeCommand.baseArgs], + env: { + ELECTRON_RUN_AS_NODE: "1", + PASEO_HOME: config.paseoHome, + }, + } + : null, ensureVoiceMcpSocketForAgent: (agentId) => voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ?? Promise.reject(new Error("Voice MCP bridge manager is not initialized")), diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index a71628887..87f104963 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -143,6 +143,7 @@ async function main() { logger.error({ pid: err.existingLock?.pid }, err.message); process.exit(1); } + logger.fatal({ err }, "Daemon bootstrap failed"); throw err; } @@ -153,6 +154,7 @@ async function main() { logger.error({ pid: err.existingLock?.pid }, err.message); process.exit(1); } + logger.fatal({ err }, "Daemon failed to start listening"); throw err; } @@ -171,10 +173,9 @@ async function main() { } main().catch((err) => { - if (process.env.PASEO_DEBUG === "1") { - process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`); - } else { - process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); - } - process.exit(1); + process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`); + // Give pino streams a moment to flush the fatal log entry to daemon.log + // before the process exits. Without this, async file streams may lose the + // last few entries that explain why the daemon crashed. + setTimeout(() => process.exit(1), 200); }); From 635de3d76ac4b01f075df1ff97cf1a174fecc554 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 19:03:45 +0700 Subject: [PATCH 02/32] debug(desktop): add renderer console-message and lifecycle event logging Pipes all renderer console.log output to the main process stdout via electron-log, and logs did-finish-load/did-fail-load/render-process-gone events to help diagnose layout and loading issues on Linux. --- packages/desktop/src/main.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index ff4c3d655..9ac733b1d 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -146,6 +146,25 @@ async function createMainWindow(): Promise { setupWindowResizeEvents(mainWindow); setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); + + // Renderer diagnostics — helps debug layout and loading issues + mainWindow.webContents.on("console-message", (_event, level, message, line, sourceId) => { + const levelNames = ["verbose", "info", "warning", "error"]; + log.info(`[renderer:console] [${levelNames[level] ?? level}] ${message} (${sourceId}:${line})`); + }); + mainWindow.webContents.on("did-finish-load", () => { + log.info("[renderer] did-finish-load"); + }); + mainWindow.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { + log.error("[renderer] did-fail-load", { errorCode, errorDescription, validatedURL }); + }); + mainWindow.webContents.on("render-process-gone", (_event, details) => { + log.error("[renderer] render-process-gone", details); + }); + mainWindow.webContents.on("unresponsive", () => { + log.warn("[renderer] unresponsive"); + }); + mainWindow.once("ready-to-show", () => { mainWindow.show(); }); From 0eac4bc4b35ee1fe253baab60c4c0f0578f963f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 19:11:06 +0700 Subject: [PATCH 03/32] fix(desktop): enable electron-log console transport for stdout output Ensures renderer console.log output (including layout-debug) is printed to stdout in packaged builds, not just the log file. Also removes unnecessary webContents event listeners. --- packages/desktop/src/main.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 9ac733b1d..e581a3600 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,4 +1,5 @@ import log from "electron-log/main"; +log.transports.console.level = "info"; log.initialize({ spyRendererConsole: true }); import { inheritLoginShellEnv } from "./login-shell-env.js"; @@ -147,24 +148,6 @@ async function createMainWindow(): Promise { setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); - // Renderer diagnostics — helps debug layout and loading issues - mainWindow.webContents.on("console-message", (_event, level, message, line, sourceId) => { - const levelNames = ["verbose", "info", "warning", "error"]; - log.info(`[renderer:console] [${levelNames[level] ?? level}] ${message} (${sourceId}:${line})`); - }); - mainWindow.webContents.on("did-finish-load", () => { - log.info("[renderer] did-finish-load"); - }); - mainWindow.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { - log.error("[renderer] did-fail-load", { errorCode, errorDescription, validatedURL }); - }); - mainWindow.webContents.on("render-process-gone", (_event, details) => { - log.error("[renderer] render-process-gone", details); - }); - mainWindow.webContents.on("unresponsive", () => { - log.warn("[renderer] unresponsive"); - }); - mainWindow.once("ready-to-show", () => { mainWindow.show(); }); From 35430dab52162065fada48913f3346b326edc36b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 19:19:07 +0700 Subject: [PATCH 04/32] chore(release): cut 0.1.51-rc.1 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e69550f1..9a651b7c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.50", + "version": "0.1.51-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.50", + "version": "0.1.51-rc.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.50", + "version": "0.1.51-rc.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.50", - "@getpaseo/highlight": "0.1.50", - "@getpaseo/server": "0.1.50", + "@getpaseo/expo-two-way-audio": "0.1.51-rc.1", + "@getpaseo/highlight": "0.1.51-rc.1", + "@getpaseo/server": "0.1.51-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35032,11 +35032,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.50", + "version": "0.1.51-rc.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.50", - "@getpaseo/server": "0.1.50", + "@getpaseo/relay": "0.1.51-rc.1", + "@getpaseo/server": "0.1.51-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35077,11 +35077,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.50", + "version": "0.1.51-rc.1", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.50", - "@getpaseo/server": "0.1.50", + "@getpaseo/cli": "0.1.51-rc.1", + "@getpaseo/server": "0.1.51-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35115,7 +35115,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.50", + "version": "0.1.51-rc.1", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35316,7 +35316,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.50", + "version": "0.1.51-rc.1", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35342,7 +35342,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.50", + "version": "0.1.51-rc.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35358,14 +35358,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.50", + "version": "0.1.51-rc.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.50", - "@getpaseo/relay": "0.1.50", + "@getpaseo/highlight": "0.1.51-rc.1", + "@getpaseo/relay": "0.1.51-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35764,7 +35764,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.50", + "version": "0.1.51-rc.1", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 26f091731..ff92d0303 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.50", + "version": "0.1.51-rc.1", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 8689b9f57..f6b83e25d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.50", + "version": "0.1.51-rc.1", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.50", - "@getpaseo/highlight": "0.1.50", - "@getpaseo/server": "0.1.50", + "@getpaseo/expo-two-way-audio": "0.1.51-rc.1", + "@getpaseo/highlight": "0.1.51-rc.1", + "@getpaseo/server": "0.1.51-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 32deaaff8..2723bfc91 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.50", + "version": "0.1.51-rc.1", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.50", - "@getpaseo/server": "0.1.50", + "@getpaseo/relay": "0.1.51-rc.1", + "@getpaseo/server": "0.1.51-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 75319fd8f..1db3658cc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.50", + "version": "0.1.51-rc.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.50", - "@getpaseo/server": "0.1.50", + "@getpaseo/cli": "0.1.51-rc.1", + "@getpaseo/server": "0.1.51-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index cd80f4c60..dee1749af 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.50", + "version": "0.1.51-rc.1", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 9b2dc37d7..3770a8065 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.50", + "version": "0.1.51-rc.1", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 71afa8dbc..224edc31b 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.50", + "version": "0.1.51-rc.1", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index 4a40a95f5..b95b805f1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.50", + "version": "0.1.51-rc.1", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.50", - "@getpaseo/relay": "0.1.50", + "@getpaseo/highlight": "0.1.51-rc.1", + "@getpaseo/relay": "0.1.51-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/website/package.json b/packages/website/package.json index e0deb6766..3c58655f8 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.50", + "version": "0.1.51-rc.1", "private": true, "type": "module", "scripts": { From 6c9a83290624734c3d3deca4f002c5375c8ee68a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 12:20:15 +0000 Subject: [PATCH 05/32] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index f931cb1cf..35aed99d4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-QFe3aBiYlt6rhYmMmBzwjph/gDHe90kZYrlin3qIpRo="; + npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). From 5eb8b300a39b5076f6eb532cd54e81ce174ab548 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 19:37:14 +0700 Subject: [PATCH 06/32] fix(ci): prevent duplicate GitHub releases in desktop workflow Add a create-release job that runs first and creates the GitHub release before any platform build jobs start. This prevents the race condition where parallel electron-builder jobs each create their own release. Platform jobs now depend on create-release and just upload assets to the existing release. Single-platform retry tags (desktop-macos-v*, desktop-linux-v*, desktop-windows-v*) skip the create-release job since the release already exists from the original build. --- .github/workflows/desktop-release.yml | 43 +++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 73ad4e2e5..29badd266 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -35,8 +35,43 @@ env: DESKTOP_PACKAGE_PATH: 'packages/desktop' jobs: + create-release: + if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }} + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: scripts + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Resolve release metadata + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Create GitHub release + if: env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists, skipping creation" + else + prerelease_flag="" + if [[ "$IS_PRERELEASE" == "true" ]]; then + prerelease_flag="--prerelease" + fi + gh release create "$RELEASE_TAG" \ + --repo "${{ github.repository }}" \ + --title "Paseo $RELEASE_TAG" \ + --generate-notes \ + $prerelease_flag + fi + publish-macos: - if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }} + needs: [create-release] + if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }} strategy: fail-fast: false matrix: @@ -219,7 +254,8 @@ jobs: run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}" publish-linux: - if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }} + needs: [create-release] + if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }} permissions: contents: write packages: read @@ -285,7 +321,8 @@ jobs: npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}" publish-windows: - if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }} + needs: [create-release] + if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }} permissions: contents: write packages: read From aae4d9f8dd06fb7f01deab34b4bcdede71e2ba95 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 21:34:37 +0700 Subject: [PATCH 07/32] docs(website): update mobile app availability status Apps are now available on App Store and Play Store. --- packages/website/src/routes/docs/updates.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/website/src/routes/docs/updates.tsx b/packages/website/src/routes/docs/updates.tsx index ea34d8103..1bfc6973e 100644 --- a/packages/website/src/routes/docs/updates.tsx +++ b/packages/website/src/routes/docs/updates.tsx @@ -77,8 +77,8 @@ function UpdatesDocs() {

Mobile apps

- Mobile apps are not yet publicly available in the App Store or Play Store. We are working - on public store availability. + Mobile apps are available on the App Store and Play Store. Update through your respective + store. Store versions may lag behind the latest release due to review processes.

From fc667d631206a686b0bdb19d020c00f44a191c64 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 22:03:30 +0700 Subject: [PATCH 08/32] fix(claude): filter messages from timeline Claude Code writes local CLI command outputs (model switches, /context, /compact, goodbye messages, etc.) as user entries in the JSONL history. These were leaking through as user_message timeline items in Paseo. --- .../agent/providers/claude-agent.test.ts | 73 +++++++++++++++++++ .../server/agent/providers/claude-agent.ts | 13 +++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 8da63128c..35c608c5f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -159,6 +159,79 @@ describe("convertClaudeHistoryEntry", () => { expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); }); + test("skips messages (model switch, /context, etc.)", () => { + // Real entries from Claude Code JSONL history files + const modelSwitch = { + type: "user", + message: { + role: "user", + content: + "Set model to claude-opus-4-6", + }, + userType: "external", + }; + + const modelSwitchWithAnsi = { + type: "user", + message: { + role: "user", + content: + 'Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m', + }, + }; + + const contextDump = { + type: "user", + message: { + role: "user", + content: + "## Context Usage\n\n**Model:** claude-opus-4-6\n**Tokens:** 19k", + }, + }; + + const planMode = { + type: "user", + message: { + role: "user", + content: "Enabled plan mode", + }, + }; + + const goodbye = { + type: "user", + message: { + role: "user", + content: "Bye!", + }, + }; + + const empty = { + type: "user", + message: { + role: "user", + content: "", + }, + }; + + const mapBlocks = vi.fn().mockReturnValue([]); + + expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]); + expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]); + expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]); + expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]); + expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]); + expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]); + + // Real user messages must NOT be filtered + const realMessage = { + type: "user", + message: { role: "user", content: "fix the bug in auth.ts" }, + }; + expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([ + { type: "user_message", text: "fix the bug in auth.ts" }, + ]); + }); + test("maps task notifications to synthetic tool calls", () => { const entry = { type: "system", diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index f936e9124..30279d37f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -418,8 +418,19 @@ function isClaudeNoResponsePlaceholderText(value: unknown): boolean { return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER; } +const LOCAL_COMMAND_STDOUT_PATTERN = /^\s*[\s\S]*<\/local-command-stdout>\s*$/; + +function isClaudeLocalCommandStdout(value: unknown): boolean { + const normalized = normalizeClaudeTranscriptText(value); + return normalized !== null && LOCAL_COMMAND_STDOUT_PATTERN.test(normalized); +} + function isClaudeTranscriptNoiseText(value: unknown): boolean { - return isClaudeInterruptPlaceholderText(value) || isClaudeNoResponsePlaceholderText(value); + return ( + isClaudeInterruptPlaceholderText(value) || + isClaudeNoResponsePlaceholderText(value) || + isClaudeLocalCommandStdout(value) + ); } function collectClaudeTextContentParts(content: unknown): string[] { From dace4f862f84d3b2e839ea4096b5d1123a74e49d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 23:24:43 +0700 Subject: [PATCH 09/32] refactor: make executable resolution async and centralize spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename findExecutable → findExecutableSync, add async findExecutable that uses promisified execFile instead of execFileSync - Simplify Windows PATH resolution: rely on inherited env from Explorer instead of shelling out to PowerShell for registry PATH entries - Add spawnProcess() helper that centralizes Windows shell/quoting concerns - Update all agent providers and consumers to use async executable resolution - Add windowsHide: true across all Windows child process calls --- .../src/poc-commands/commands-poc.test.ts | 4 +- .../agent/provider-launch-config.test.ts | 12 +- .../server/agent/provider-launch-config.ts | 18 +- ...ude-agent.event-stream.integration.test.ts | 4 +- .../src/server/agent/providers/acp-agent.ts | 31 +- .../claude-agent-commands.real.e2e.test.ts | 4 +- .../claude-agent.integration.test.ts | 6 +- .../claude-agent.max-effort.real.e2e.test.ts | 4 +- .../server/agent/providers/claude-agent.ts | 48 +- .../providers/claude-sdk-behavior.test.ts | 8 +- .../agent/providers/codex-app-server-agent.ts | 48 +- .../agent/providers/copilot-acp-agent.ts | 4 +- .../agent/providers/diagnostic-utils.ts | 19 +- .../opencode-agent-commands.real.e2e.test.ts | 18 +- .../agent/providers/opencode-agent.test.ts | 11 +- .../server/agent/providers/opencode-agent.ts | 429 +++++++----------- .../server/agent/providers/pi-acp-agent.ts | 6 +- .../src/server/daemon-e2e/agent-configs.ts | 12 +- .../server/src/server/editor-targets.test.ts | 12 +- packages/server/src/server/editor-targets.ts | 39 +- packages/server/src/server/exports.ts | 3 +- packages/server/src/utils/checkout-git.ts | 17 +- packages/server/src/utils/executable.test.ts | 64 +-- packages/server/src/utils/executable.ts | 108 ++--- packages/server/src/utils/spawn.ts | 42 ++ 25 files changed, 449 insertions(+), 522 deletions(-) create mode 100644 packages/server/src/utils/spawn.ts diff --git a/packages/server/src/poc-commands/commands-poc.test.ts b/packages/server/src/poc-commands/commands-poc.test.ts index 7ca97cef4..f6af6c8bb 100644 --- a/packages/server/src/poc-commands/commands-poc.test.ts +++ b/packages/server/src/poc-commands/commands-poc.test.ts @@ -20,11 +20,11 @@ import { query, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; -import { isCommandAvailable } from "../utils/executable.js"; +import { isCommandAvailableSync } from "../utils/executable.js"; const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; -const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; +const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; // Pattern from claude-agent.ts listModels(): // Use an empty async generator when you just need control methods diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 4d96d37b9..a172ee0a7 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -7,19 +7,19 @@ import { } from "./provider-launch-config.js"; describe("resolveProviderCommandPrefix", () => { - test("uses resolved default command in default mode", () => { + test("uses resolved default command in default mode", async () => { const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); - const resolved = resolveProviderCommandPrefix(undefined, resolveDefault); + const resolved = await resolveProviderCommandPrefix(undefined, resolveDefault); expect(resolveDefault).toHaveBeenCalledTimes(1); expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] }); }); - test("appends args in append mode", () => { + test("appends args in append mode", async () => { const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); - const resolved = resolveProviderCommandPrefix( + const resolved = await resolveProviderCommandPrefix( { mode: "append", args: ["--chrome"], @@ -34,10 +34,10 @@ describe("resolveProviderCommandPrefix", () => { }); }); - test("replaces command in replace mode without resolving default", () => { + test("replaces command in replace mode without resolving default", async () => { const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); - const resolved = resolveProviderCommandPrefix( + const resolved = await resolveProviderCommandPrefix( { mode: "replace", argv: ["docker", "run", "--rm", "my-wrapper"], diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 730b591f2..66bf83944 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -53,20 +53,20 @@ export type ProviderCommandPrefix = { args: string[]; }; -export function resolveProviderCommandPrefix( +export async function resolveProviderCommandPrefix( commandConfig: ProviderCommand | undefined, - resolveDefaultCommand: () => string, -): ProviderCommandPrefix { + resolveDefaultCommand: () => string | Promise, +): Promise { if (!commandConfig || commandConfig.mode === "default") { return { - command: resolveDefaultCommand(), + command: await resolveDefaultCommand(), args: [], }; } if (commandConfig.mode === "append") { return { - command: resolveDefaultCommand(), + command: await resolveDefaultCommand(), args: [...(commandConfig.args ?? [])], }; } @@ -102,12 +102,12 @@ export function applyProviderEnv( return merged; } -export function isProviderCommandAvailable( +export async function isProviderCommandAvailable( commandConfig: ProviderCommand | undefined, - resolveDefaultCommand: () => string, -): boolean { + resolveDefaultCommand: () => string | Promise, +): Promise { try { - const prefix = resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand); + const prefix = await resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand); return isCommandAvailable(prefix.command); } catch { return false; diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts index 14c684297..02ff31a7c 100644 --- a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts +++ b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts @@ -17,7 +17,7 @@ import path from "node:path"; import pino from "pino"; import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; -import { isCommandAvailable } from "../../../../utils/executable.js"; +import { isCommandAvailableSync } from "../../../../utils/executable.js"; import { ClaudeAgentClient } from "../claude-agent.js"; // --------------------------------------------------------------------------- @@ -28,7 +28,7 @@ const logger = pino({ level: "silent" }); const client = new ClaudeAgentClient({ logger }); const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; -const canRun = isCommandAvailable("claude") && hasClaudeCredentials; +const canRun = isCommandAvailableSync("claude") && hasClaudeCredentials; function tmpCwd(prefix: string): string { return mkdtempSync(path.join(tmpdir(), prefix)); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 6b41a3825..0db635aac 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1,5 +1,4 @@ import { - spawn, type ChildProcess, type ChildProcessWithoutNullStreams, } from "node:child_process"; @@ -89,6 +88,7 @@ import { type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { findExecutable } from "../../../utils/executable.js"; +import { spawnProcess } from "../../../utils/spawn.js"; const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -476,16 +476,15 @@ export class ACPAgentClient implements AgentClient { protected async spawnProcess( launchEnv?: Record, ): Promise { - const { command, args } = this.resolveLaunchCommand(); - const child = spawn(command, args, { + const { command, args } = await this.resolveLaunchCommand(); + const child = spawnProcess(command, args, { cwd: process.cwd(), env: { ...applyProviderEnv(process.env as Record, this.runtimeSettings), ...(launchEnv ?? {}), }, - shell: process.platform === "win32", stdio: ["pipe", "pipe", "pipe"], - }); + }) as ChildProcessWithoutNullStreams; const stderrChunks: string[] = []; child.stderr.on("data", (chunk: Buffer | string) => { @@ -552,9 +551,9 @@ export class ACPAgentClient implements AgentClient { } } - protected resolveLaunchCommand(): { command: string; args: string[] } { - const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { - const resolved = findExecutable(this.defaultCommand[0]); + protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { + const resolved = await findExecutable(this.defaultCommand[0]); + const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { if (!resolved) { throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); } @@ -1172,13 +1171,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> { const terminalId = randomUUID(); const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value])); - const child = spawn(params.command, params.args ?? [], { + const child = spawnProcess(params.command, params.args ?? [], { cwd: params.cwd ?? this.config.cwd, env: { ...applyProviderEnv(process.env as Record, this.runtimeSettings), ...env, }, - shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"], }); @@ -1201,8 +1199,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { rejectExit, }; - child.stdout.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); - child.stderr.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); + child.stdout!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); + child.stderr!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error)))); child.once("exit", (code, signal) => { const exit = { exitCode: code, signal }; @@ -1245,8 +1243,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { } private async spawnProcess(): Promise { - const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { - const resolved = findExecutable(this.defaultCommand[0]); + const resolved = await findExecutable(this.defaultCommand[0]); + const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { if (!resolved) { throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); } @@ -1255,15 +1253,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { const command = prefix.command; const args = [...prefix.args, ...this.defaultCommand.slice(1)]; - const child = spawn(command, args, { + const child = spawnProcess(command, args, { cwd: this.config.cwd, env: { ...applyProviderEnv(process.env as Record, this.runtimeSettings), ...(this.launchEnv ?? {}), }, - shell: process.platform === "win32", stdio: ["pipe", "pipe", "pipe"], - }); + }) as ChildProcessWithoutNullStreams; const stderrChunks: string[] = []; child.stderr.on("data", (chunk: Buffer | string) => { diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts index baf548bfe..6bdbe3a14 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts @@ -2,13 +2,13 @@ import { describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentSlashCommand } from "../agent-sdk-types.js"; -import { isCommandAvailable } from "../../../utils/executable.js"; +import { isCommandAvailableSync } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; // Real-Claude contract coverage: validates slash command shape from a live Claude CLI session. describe("claude agent commands contract (real)", () => { test("lists slash commands with the expected contract", async () => { - expect(isCommandAvailable("claude")).toBe(true); + expect(isCommandAvailableSync("claude")).toBe(true); const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }), diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts index 90c7d2ca4..a9e58a59a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts @@ -6,7 +6,7 @@ import pino from "pino"; import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; -import { isCommandAvailable } from "../../../utils/executable.js"; +import { isCommandAvailableSync } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -174,11 +174,11 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P } describe("ClaudeAgentSession integration", () => { - const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; + const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; beforeAll(() => { if (canRunClaudeIntegration) { - expect(isCommandAvailable("claude")).toBe(true); + expect(isCommandAvailableSync("claude")).toBe(true); } }); diff --git a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts index 34f5fe5b0..47f0ea9fc 100644 --- a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js"; -import { isCommandAvailable } from "../../../utils/executable.js"; +import { isCommandAvailableSync } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -29,7 +29,7 @@ async function collectUntilTerminal(session: AgentSession): Promise { - test.runIf(isCommandAvailable("claude") && hasClaudeCredentials)( + test.runIf(isCommandAvailableSync("claude") && hasClaudeCredentials)( "surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", async () => { const client = new ClaudeAgentClient({ diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 30279d37f..2218a0fc5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,4 +1,5 @@ -import { execFileSync, spawn } from "node:child_process"; +import { execFile, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { promisify } from "node:util"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; @@ -79,14 +80,12 @@ import { applyProviderEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { - findExecutable, - quoteWindowsArgument, - quoteWindowsCommand, -} from "../../../utils/executable.js"; +import { findExecutable } from "../../../utils/executable.js"; +import { spawnProcess } from "../../../utils/spawn.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; const fsPromises = promises; +const execFileAsync = promisify(execFile); const CLAUDE_SETTING_SOURCES: NonNullable = ["user", "project"]; type TurnState = "idle" | "foreground" | "autonomous"; @@ -225,26 +224,21 @@ function applyRuntimeSettingsToClaudeOptions( const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun"; const command = isDefaultRuntime ? process.execPath : resolved.command; - const child = spawn( - quoteWindowsCommand(command), - resolved.args.map((argument) => quoteWindowsArgument(argument)), - { + const child = spawnProcess(command, resolved.args, { cwd: spawnOptions.cwd, env: { ...applyProviderEnv(spawnOptions.env, runtimeSettings), ...(launchEnv ?? {}), }, - shell: process.platform === "win32", signal: spawnOptions.signal, stdio: ["pipe", "pipe", "pipe"], - }, - ); + }); if (typeof options.stderr === "function") { child.stderr?.on("data", (chunk: Buffer | string) => { options.stderr?.(chunk.toString()); }); } - return child; + return child as ChildProcessWithoutNullStreams; }, }; } @@ -1145,9 +1139,9 @@ export class ClaudeAgentClient implements AgentClient { async getDiagnostic(): Promise<{ diagnostic: string }> { try { - const resolvedBinary = findExecutable("claude") ?? "not found"; + const resolvedBinary = await findExecutable("claude") ?? "not found"; const available = await this.isAvailable(); - const version = resolveClaudeVersion(this.runtimeSettings); + const version = await resolveClaudeVersion(this.runtimeSettings); let modelsValue = "Not checked"; let status = formatDiagnosticStatus(available); @@ -1187,26 +1181,30 @@ export class ClaudeAgentClient implements AgentClient { } } -function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string | null { +async function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): Promise { const command = runtimeSettings?.command; try { if (command?.mode === "replace") { - return execFileSync(command.argv[0]!, [...command.argv.slice(1), "--version"], { + const { stdout } = await execFileAsync(command.argv[0]!, [...command.argv.slice(1), "--version"], { encoding: "utf8", timeout: 5_000, - }).trim() || null; + windowsHide: true, + }); + return stdout.trim() || null; } - const executable = findExecutable("claude"); + const executable = await findExecutable("claude"); if (!executable) { return null; } - return execFileSync(executable, ["--version"], { + const { stdout } = await execFileAsync(executable, ["--version"], { encoding: "utf8", timeout: 5_000, - }).trim() || null; + windowsHide: true, + }); + return stdout.trim() || null; } catch { return null; } @@ -2028,7 +2026,7 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; const input = createAsyncMessageInput(); - const options = this.buildOptions(); + const options = await this.buildOptions(); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; this.query = this.queryFactory({ prompt: input.iterable, options }); @@ -2065,7 +2063,7 @@ class ClaudeAgentSession implements AgentSession { } } - private buildOptions(): ClaudeOptions { + private async buildOptions(): Promise { const thinkingOptionId = this.config.thinkingOptionId && this.config.thinkingOptionId !== "default" ? this.config.thinkingOptionId @@ -2084,7 +2082,7 @@ class ClaudeAgentSession implements AgentSession { .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) .join("\n\n"); - const claudeBinary = findExecutable("claude"); + const claudeBinary = await findExecutable("claude"); this.logger.debug( { claudeBinary, diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts index 2b330830b..62b373c98 100644 --- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts +++ b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { beforeAll, describe, expect, test } from "vitest"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailableSync } from "../../../utils/executable.js"; class Pushable implements AsyncIterable { private queue: T[] = []; @@ -57,18 +57,18 @@ const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; describe("Claude SDK direct behavior", () => { - const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; + const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; beforeAll(() => { if (canRunClaudeIntegration) { - expect(isCommandAvailable("claude")).toBe(true); + expect(isCommandAvailableSync("claude")).toBe(true); } }); test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => { const cwd = tmpCwd(); const input = new Pushable(); - const claudeBinary = findExecutable("claude"); + const claudeBinary = await findExecutable("claude"); // Use same options as claude-agent.ts const q = query({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 35baca8d2..a6a59d74e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -27,7 +27,7 @@ import type { } from "../agent-sdk-types.js"; import type { Logger } from "pino"; -import { execSync, spawn } from "node:child_process"; +import { execSync } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, Dirent } from "node:fs"; @@ -46,11 +46,8 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { - findExecutable, - quoteWindowsArgument, - quoteWindowsCommand, -} from "../../../utils/executable.js"; +import { findExecutable } from "../../../utils/executable.js"; +import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; import { @@ -228,8 +225,8 @@ function mergeCodexConfiguredDefaults( }; } -function resolveCodexBinary(): string { - const found = findExecutable("codex"); +async function resolveCodexBinary(): Promise { + const found = await findExecutable("codex"); if (found) { return found; } @@ -238,10 +235,10 @@ function resolveCodexBinary(): string { ); } -function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): { +async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{ command: string; args: string[]; -} { +}> { return resolveProviderCommandPrefix(runtimeSettings?.command, resolveCodexBinary); } @@ -2457,7 +2454,7 @@ class CodexAppServerAgentSession implements AgentSession { config: AgentSessionConfig, private readonly resumeHandle: { sessionId: string; metadata?: Record } | null, logger: Logger, - private readonly spawnAppServer: () => ChildProcessWithoutNullStreams, + private readonly spawnAppServer: () => Promise, ) { this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER }); if (config.modeId === undefined) { @@ -2495,7 +2492,7 @@ class CodexAppServerAgentSession implements AgentSession { async connect(): Promise { if (this.connected) return; - const child = this.spawnAppServer(); + const child = await this.spawnAppServer(); this.client = new CodexAppServerClient(child, this.logger); this.client.setNotificationHandler((method, params) => this.handleNotification(method, params)); this.registerRequestHandlers(); @@ -3997,24 +3994,19 @@ export class CodexAppServerAgentClient implements AgentClient { private readonly runtimeSettings?: ProviderRuntimeSettings, ) {} - private spawnAppServer(launchEnv?: Record): ChildProcessWithoutNullStreams { - const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings); + private async spawnAppServer(launchEnv?: Record): Promise { + const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); this.logger.trace( { launchPrefix, }, "Spawning Codex app server", ); - return spawn( - quoteWindowsCommand(launchPrefix.command), - [...launchPrefix.args, "app-server"].map((argument) => quoteWindowsArgument(argument)), - { - detached: process.platform !== "win32", - shell: process.platform === "win32", - stdio: ["pipe", "pipe", "pipe"], - env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv), - }, - ); + return spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], { + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv), + }) as ChildProcessWithoutNullStreams; } async createSession( @@ -4051,7 +4043,7 @@ export class CodexAppServerAgentClient implements AgentClient { async listPersistedAgents( options?: ListPersistedAgentsOptions, ): Promise { - const child = this.spawnAppServer(); + const child = await this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger); try { @@ -4121,7 +4113,7 @@ export class CodexAppServerAgentClient implements AgentClient { } async listModels(_options?: ListModelsOptions): Promise { - const child = this.spawnAppServer(); + const child = await this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger); try { @@ -4213,13 +4205,13 @@ export class CodexAppServerAgentClient implements AgentClient { async getDiagnostic(): Promise<{ diagnostic: string }> { try { const available = await this.isAvailable(); - const resolvedBinary = findExecutable("codex"); + const resolvedBinary = await findExecutable("codex"); const entries: Array<{ label: string; value: string }> = [ { label: "Binary", value: resolvedBinary ?? "not found", }, - { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" }, ]; let status = formatDiagnosticStatus(available); diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index 395508336..a2ff16175 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -63,7 +63,7 @@ export class CopilotACPAgentClient extends ACPAgentClient { async getDiagnostic(): Promise<{ diagnostic: string }> { try { const available = await this.isAvailable(); - const resolvedBinary = findExecutable("copilot"); + const resolvedBinary = await findExecutable("copilot"); let modelsValue = "Not checked"; let status = formatDiagnosticStatus(available); @@ -97,7 +97,7 @@ export class CopilotACPAgentClient extends ACPAgentClient { label: "Binary", value: resolvedBinary ?? "not found", }, - { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" }, { label: "Models", value: modelsValue }, { label: "Status", value: status }, ]), diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts index af077b271..7869d79a3 100644 --- a/packages/server/src/server/agent/providers/diagnostic-utils.ts +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -1,7 +1,10 @@ -import { execFileSync } from "node:child_process"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; +const execFileAsync = promisify(execFile); + type DiagnosticEntry = { label: string; value: string; @@ -50,14 +53,14 @@ export function toDiagnosticErrorMessage(error: unknown): string { return "Unknown error"; } -export function resolveBinaryVersion(binaryPath: string): string { +export async function resolveBinaryVersion(binaryPath: string): Promise { try { - return ( - execFileSync(binaryPath, ["--version"], { - encoding: "utf8", - timeout: 5_000, - }).trim() || "unknown" - ); + const { stdout } = await execFileAsync(binaryPath, ["--version"], { + encoding: "utf8", + timeout: 5_000, + windowsHide: true, + }); + return stdout.trim() || "unknown"; } catch { return "unknown"; } diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts index ca9a49ae6..3ee10d777 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts @@ -2,12 +2,12 @@ import { describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentSlashCommand } from "../agent-sdk-types.js"; -import { isCommandAvailable } from "../../../utils/executable.js"; +import { isCommandAvailableSync } from "../../../utils/executable.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; describe("opencode agent commands contract (real)", () => { test("lists slash commands with the expected contract", async () => { - expect(isCommandAvailable("opencode")).toBe(true); + expect(isCommandAvailableSync("opencode")).toBe(true); const client = new OpenCodeAgentClient(pino({ level: "silent" })); const session = await client.createSession({ @@ -37,13 +37,13 @@ describe("opencode agent commands contract (real)", () => { }, 60_000); test("executes a slash command without arguments", async () => { - expect(isCommandAvailable("opencode")).toBe(true); + expect(isCommandAvailableSync("opencode")).toBe(true); const client = new OpenCodeAgentClient(pino({ level: "silent" })); const session = await client.createSession({ provider: "opencode", cwd: process.cwd(), - modeId: "default", + modeId: "plan", }); try { @@ -58,14 +58,22 @@ describe("opencode agent commands contract (real)", () => { const { turnId } = await session.startTurn(`/${command.name}`); expect(turnId).toBeTruthy(); - // Wait for the turn to complete or fail. + // Wait for any terminal event OR a short timeout. + // Some commands trigger full AI turns that may hang waiting for tool + // permissions. The test only needs to verify the invocation path + // doesn't crash with type errors on the `arguments` field. await new Promise((resolve) => { + const timeout = setTimeout(() => { + unsub(); + resolve(); + }, 15_000); const unsub = session.subscribe((event) => { if ( event.type === "turn_completed" || event.type === "turn_failed" || event.type === "turn_canceled" ) { + clearTimeout(timeout); unsub(); resolve(); } diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 2e220b9ba..72f3fbdb1 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { execFileSync } from "node:child_process"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { Event as OpenCodeEvent } from "@opencode-ai/sdk/v2/client"; import { __openCodeInternals, OpenCodeAgentClient, @@ -116,15 +117,15 @@ const hasOpenCode = isBinaryInstalled("opencode"); "beforeAll: Retrieved models", ); - // Prefer fast models for tests - nano models are typically fastest + // Prefer cheap models that support tool use (required by OpenCode agents). + // Avoid free-tier OpenRouter models — they often lack tool-use support. const fastModel = models.find( (m) => m.id.includes("gpt-4.1-nano") || + m.id.includes("gpt-4.1-mini") || m.id.includes("gpt-5-nano") || m.id.includes("gpt-5.1-codex-mini") || - m.id.includes("gpt-4o-mini") || - m.id.includes("gpt-3.5") || - m.id.includes("free"), + m.id.includes("gpt-4o-mini"), ); if (fastModel) { @@ -442,7 +443,7 @@ describe("OpenCode adapter context-window normalization", () => { modelID: "gpt-5", }, }, - }, + } as OpenCodeEvent, { sessionId: "session-1", messageRoles: new Map(), diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index fcb93de7d..9ac9eba51 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,8 +1,11 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import { createOpencodeClient, + type AssistantMessage as OpenCodeAssistantMessage, + type Event as OpenCodeEvent, type OpencodeClient, + type Part as OpenCodePart, } from "@opencode-ai/sdk/v2/client"; import net from "node:net"; import type { Logger } from "pino"; @@ -12,7 +15,6 @@ import type { AgentCapabilityFlags, AgentClient, AgentLaunchContext, - AgentMetadata, AgentMode, AgentModelDefinition, AgentPermissionRequest, @@ -40,11 +42,8 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { - findExecutable, - quoteWindowsArgument, - quoteWindowsCommand, -} from "../../../utils/executable.js"; +import { findExecutable } from "../../../utils/executable.js"; +import { spawnProcess } from "../../../utils/spawn.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { formatDiagnosticStatus, @@ -174,8 +173,8 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch }), ); -function resolveOpenCodeBinary(): string { - const found = findExecutable("opencode"); +async function resolveOpenCodeBinary(): Promise { + const found = await findExecutable("opencode"); if (found) { return found; } @@ -239,14 +238,15 @@ async function findAvailablePort(): Promise { }); } -function resolvePartDedupeKey(part: AgentMetadata, partType: "text" | "reasoning"): string | null { - const partId = part.id; - if (typeof partId === "string" && partId.trim().length > 0) { - return `${partType}:${partId}`; +function resolvePartDedupeKey( + part: { id: string; messageID: string }, + partType: "text" | "reasoning", +): string | null { + if (part.id.trim().length > 0) { + return `${partType}:${part.id}`; } - const messageId = part.messageID; - if (typeof messageId === "string" && messageId.trim().length > 0) { - return `${partType}:message:${messageId}`; + if (part.messageID.trim().length > 0) { + return `${partType}:message:${part.messageID}`; } return null; } @@ -404,15 +404,11 @@ function buildOpenCodeModelContextWindowLookup(providers: { return lookup; } -function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo( - info: AgentMetadata | undefined, +function resolveOpenCodeModelLookupKeyFromAssistantMessage( + info: OpenCodeAssistantMessage, ): string | undefined { - if (!info || info.role !== "assistant") { - return undefined; - } - - const providerId = readNonEmptyString(info.providerID); - const modelId = readNonEmptyString(info.modelID); + const providerId = info.providerID; + const modelId = info.modelID; if (!providerId || !modelId) { return undefined; } @@ -485,7 +481,7 @@ export const __openCodeInternals = { hasNormalizedOpenCodeUsage, mergeOpenCodeStepFinishUsage, parseOpenCodeModelLookupKey, - resolveOpenCodeModelLookupKeyFromAssistantMessageInfo, + resolveOpenCodeModelLookupKeyFromAssistantMessage, resolveOpenCodeSelectedModelContextWindow, }; @@ -564,19 +560,16 @@ export class OpenCodeServerManager { private async startServer(): Promise<{ port: number; url: string }> { this.port = await findAvailablePort(); const url = `http://127.0.0.1:${this.port}`; - const launchPrefix = resolveProviderCommandPrefix( + const launchPrefix = await resolveProviderCommandPrefix( this.runtimeSettings?.command, resolveOpenCodeBinary, ); return new Promise((resolve, reject) => { - this.server = spawn( - quoteWindowsCommand(launchPrefix.command), - [...launchPrefix.args, "serve", "--port", String(this.port)].map((argument) => - quoteWindowsArgument(argument), - ), + this.server = spawnProcess( + launchPrefix.command, + [...launchPrefix.args, "serve", "--port", String(this.port)], { - shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"], env: applyProviderEnv(process.env, this.runtimeSettings), }, @@ -847,7 +840,7 @@ export class OpenCodeAgentClient implements AgentClient { async getDiagnostic(): Promise<{ diagnostic: string }> { try { const available = await this.isAvailable(); - const resolvedBinary = findExecutable("opencode"); + const resolvedBinary = await findExecutable("opencode"); let serverStatus = "Not running"; let modelsValue = "Not checked"; let status = formatDiagnosticStatus(available); @@ -889,7 +882,7 @@ export class OpenCodeAgentClient implements AgentClient { label: "Binary", value: resolvedBinary ?? "not found", }, - { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" }, { label: "Server", value: serverStatus }, { label: "Models", value: modelsValue }, { label: "Status", value: status }, @@ -962,51 +955,18 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function normalizeQuestionOptions( - value: unknown, -): Array<{ label: string; description?: string }> | null { - if (!Array.isArray(value)) { - return null; - } - const options: Array<{ label: string; description?: string }> = []; - for (const item of value) { - if (typeof item === "string" && item.trim().length > 0) { - options.push({ label: item.trim() }); - continue; - } - - const record = readOpenCodeRecord(item); - const label = readNonEmptyString(record?.label); - if (!label) { - continue; - } - const description = readNonEmptyString(record?.description); - options.push(description ? { label, description } : { label }); - } - - return options; -} export function translateOpenCodeEvent( - event: unknown, + event: OpenCodeEvent, state: OpenCodeEventTranslationState, ): AgentStreamEvent[] { const events: AgentStreamEvent[] = []; - if (!event || typeof event !== "object") { - return events; - } - - const e = event as { type?: string; properties?: AgentMetadata }; - const type = e.type; - const props = e.properties ?? {}; - - switch (type) { + switch (event.type) { case "session.created": case "session.updated": { - const sessionId = props.id as string | undefined; - if (sessionId === state.sessionId) { + if (event.properties.info.id === state.sessionId) { events.push({ type: "thread_started", sessionId: state.sessionId, @@ -1017,35 +977,28 @@ export function translateOpenCodeEvent( } case "message.updated": { - const info = props.info as AgentMetadata | undefined; - if (!info) { + const info = event.properties.info; + if (info.sessionID !== state.sessionId) { break; } - const messageId = info.id as string | undefined; - const messageSessionId = info.sessionID as string | undefined; - const role = info.role as OpenCodeMessageRole | undefined; - if (messageId && messageSessionId === state.sessionId && role) { - state.messageRoles.set(messageId, role); - if (role === "assistant") { - const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info); - if (modelLookupKey) { - const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey); - if (contextWindowMaxTokens !== undefined) { - state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens); - } + state.messageRoles.set(info.id, info.role); + if (info.role === "assistant") { + const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info); + if (modelLookupKey) { + const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey); + if (contextWindowMaxTokens !== undefined) { + state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens); } } + if ( - role === "assistant" && - !state.emittedStructuredMessageIds.has(messageId) && - typeof info.time === "object" && - info.time !== null && - "completed" in info.time + !state.emittedStructuredMessageIds.has(info.id) && + info.time?.completed !== undefined ) { const text = stringifyStructuredAssistantMessage(info.structured); if (text) { - state.emittedStructuredMessageIds.add(messageId); + state.emittedStructuredMessageIds.add(info.id); events.push({ type: "timeline", provider: "opencode", @@ -1058,82 +1011,46 @@ export function translateOpenCodeEvent( } case "message.part.updated": { - const part = props.part as AgentMetadata | undefined; - const delta = props.delta as string | undefined; - if (!part) { + const part = event.properties.part; + if (part.sessionID !== state.sessionId) { break; } - const partSessionId = part.sessionID as string | undefined; - if (partSessionId !== state.sessionId) { - break; - } + const messageRole = state.messageRoles.get(part.messageID); + state.partTypes.set(part.id, part.type); - const partId = part.id as string | undefined; - const messageId = part.messageID as string | undefined; - const messageRole = messageId ? state.messageRoles.get(messageId) : undefined; - const partType = part.type as string | undefined; - const partTime = part.time as { start?: number; end?: number } | undefined; - - if (partId && partType) { - state.partTypes.set(partId, partType); - } - - if (partType === "text") { + if (part.type === "text") { const partKey = resolvePartDedupeKey(part, "text"); if (messageRole === "user") { break; } - if (!messageRole && !delta) { - break; - } - if (delta) { - if (partKey) { - state.streamedPartKeys.add(partKey); - } - events.push({ - type: "timeline", - provider: "opencode", - item: { type: "assistant_message", text: delta }, - }); - } else if (partTime?.end) { + if (part.time?.end) { if (partKey && state.streamedPartKeys.delete(partKey)) { break; } - const text = part.text as string | undefined; - if (text) { + if (part.text) { events.push({ type: "timeline", provider: "opencode", - item: { type: "assistant_message", text }, + item: { type: "assistant_message", text: part.text }, }); } } - } else if (partType === "reasoning") { + } else if (part.type === "reasoning") { const partKey = resolvePartDedupeKey(part, "reasoning"); - if (delta) { - if (partKey) { - state.streamedPartKeys.add(partKey); - } - events.push({ - type: "timeline", - provider: "opencode", - item: { type: "reasoning", text: delta }, - }); - } else if (partTime?.end) { + if (part.time.end) { if (partKey && state.streamedPartKeys.delete(partKey)) { break; } - const text = part.text as string | undefined; - if (text) { + if (part.text) { events.push({ type: "timeline", provider: "opencode", - item: { type: "reasoning", text }, + item: { type: "reasoning", text: part.text }, }); } } - } else if (partType === "tool") { + } else if (part.type === "tool") { const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); if (parsedToolPart.success && parsedToolPart.data) { events.push({ @@ -1142,7 +1059,7 @@ export function translateOpenCodeEvent( item: parsedToolPart.data, }); } - } else if (partType === "step-finish") { + } else if (part.type === "step-finish") { mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) { events.push({ @@ -1156,111 +1073,84 @@ export function translateOpenCodeEvent( } case "message.part.delta": { - const deltaSessionId = props.sessionID as string | undefined; - if (deltaSessionId !== state.sessionId) { + const { sessionID, messageID, partID, field, delta } = event.properties; + if (sessionID !== state.sessionId) { break; } - const deltaMessageId = props.messageID as string | undefined; - const deltaMessageRole = deltaMessageId - ? state.messageRoles.get(deltaMessageId) - : undefined; - const deltaField = props.field as string | undefined; - const deltaText = props.delta as string | undefined; - - if (!deltaText || !deltaField) { + if (!delta || !field) { break; } - const partId = props.partID as string | undefined; - const knownPartType = partId ? state.partTypes.get(partId) : undefined; - const isReasoning = knownPartType === "reasoning" || deltaField === "reasoning"; + const messageRole = messageID ? state.messageRoles.get(messageID) : undefined; + const knownPartType = partID ? state.partTypes.get(partID) : undefined; + const isReasoning = knownPartType === "reasoning" || field === "reasoning"; if (isReasoning) { - const partKey = partId ? `reasoning:${partId}` : null; - if (partKey) { - state.streamedPartKeys.add(partKey); + if (partID) { + state.streamedPartKeys.add(`reasoning:${partID}`); } events.push({ type: "timeline", provider: "opencode", - item: { type: "reasoning", text: deltaText }, + item: { type: "reasoning", text: delta }, }); - } else if (deltaField === "text") { - if (deltaMessageRole === "user") { + } else if (field === "text") { + if (messageRole === "user") { break; } - const partKey = partId ? `text:${partId}` : null; - if (partKey) { - state.streamedPartKeys.add(partKey); + if (partID) { + state.streamedPartKeys.add(`text:${partID}`); } events.push({ type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: deltaText }, + item: { type: "assistant_message", text: delta }, }); } break; } case "permission.asked": { - const sessionId = props.sessionID as string | undefined; - if (sessionId !== state.sessionId) { + if (event.properties.sessionID !== state.sessionId) { break; } - const requestId = props.id as string; - const permission = props.permission as string; - const metadata = props.metadata as AgentMetadata | undefined; - const patterns = props.patterns as string[] | undefined; - - const permRequest: AgentPermissionRequest = { - id: requestId, - provider: "opencode", - name: permission, - kind: "tool", - title: permission, - description: patterns?.join(", "), - input: metadata, - }; - events.push({ type: "permission_requested", provider: "opencode", - request: permRequest, + request: { + id: event.properties.id, + provider: "opencode", + name: event.properties.permission, + kind: "tool", + title: event.properties.permission, + description: event.properties.patterns?.join(", "), + input: event.properties.metadata, + }, }); break; } case "question.asked": { - const sessionId = props.sessionID as string | undefined; - if (sessionId !== state.sessionId) { + if (event.properties.sessionID !== state.sessionId) { break; } - const requestId = props.id as string | undefined; - const rawQuestions = Array.isArray(props.questions) ? props.questions : []; - if (!requestId || rawQuestions.length === 0) { - break; - } - - const questions = rawQuestions.flatMap((item) => { - const questionRecord = readOpenCodeRecord(item); - const question = readNonEmptyString(questionRecord?.question); - const header = readNonEmptyString(questionRecord?.header); - if (!question || !header) { + const questions = event.properties.questions.flatMap((q) => { + if (!q.question || !q.header) { return []; } - - const options = normalizeQuestionOptions(questionRecord?.options) ?? []; - return [ - { - question, - header, - options, - ...(questionRecord?.multiple === true ? { multiSelect: true } : {}), - }, - ]; + const options = q.options?.map((o) => ({ + label: o.label, + ...(o.description ? { description: o.description } : {}), + })) ?? []; + return [{ + question: q.question, + header: q.header, + options, + ...(q.multiple === true ? { multiSelect: true } : {}), + }]; }); if (questions.length === 0) { @@ -1271,7 +1161,7 @@ export function translateOpenCodeEvent( type: "permission_requested", provider: "opencode", request: { - id: requestId, + id: event.properties.id, provider: "opencode", name: "question", kind: "question", @@ -1279,7 +1169,7 @@ export function translateOpenCodeEvent( input: { questions }, metadata: { source: "opencode_question", - ...(readOpenCodeRecord(props.tool) ?? {}), + ...(event.properties.tool ?? {}), }, }, }); @@ -1287,8 +1177,7 @@ export function translateOpenCodeEvent( } case "session.idle": { - const sessionId = props.sessionID as string | undefined; - if (sessionId === state.sessionId) { + if (event.properties.sessionID === state.sessionId) { state.streamedPartKeys.clear(); state.partTypes.clear(); events.push({ @@ -1301,18 +1190,35 @@ export function translateOpenCodeEvent( } case "session.error": { - const sessionId = props.sessionID as string | undefined; - if (sessionId === state.sessionId) { + if (event.properties.sessionID === state.sessionId) { state.streamedPartKeys.clear(); state.partTypes.clear(); events.push({ type: "turn_failed", provider: "opencode", - error: normalizeTurnFailureError(props.error), + error: normalizeTurnFailureError(event.properties.error), }); } break; } + + case "session.status": { + if (event.properties.sessionID !== state.sessionId) { + break; + } + const { status } = event.properties; + if (status.type === "idle") { + state.streamedPartKeys.clear(); + state.partTypes.clear(); + events.push({ + type: "turn_completed", + provider: "opencode", + usage: undefined, + }); + } + // "retry" and "busy" are transient — no terminal event. + break; + } } return events; @@ -1347,7 +1253,6 @@ class OpenCodeAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private readonly runningToolCalls = new Map(); private selectedModelContextWindowMaxTokens: number | undefined; - constructor( config: OpenCodeAgentConfig, client: OpencodeClient, @@ -1513,8 +1418,12 @@ class OpenCodeAgentSession implements AgentSession { const slashCommand = await this.resolveSlashCommandInvocation(prompt); if (slashCommand) { - // command() blocks until completion, but events stream via SSE in the - // background — fire without awaiting so the event stream isn't starved. + // command() blocks until the server finishes processing. OpenCode's SSE + // endpoint does NOT replay past events, so if the command completes before + // our SSE reader connects, we miss `session.idle` and the turn hangs. + // Handle both success and error in the response handler as a fallback — + // finishForegroundTurn's guard prevents duplicate terminal events if the + // SSE stream already delivered the event. void this.client.session.command({ sessionID: this.sessionId, directory: this.config.cwd, @@ -1530,7 +1439,17 @@ class OpenCodeAgentSession implements AgentSession { { type: "turn_failed", provider: "opencode", error: errorMsg }, turnId, ); + } else { + this.finishForegroundTurn( + { type: "turn_completed", provider: "opencode", usage: undefined }, + turnId, + ); } + }).catch((err) => { + this.finishForegroundTurn( + { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) }, + turnId, + ); }); } else { const promptResponse = await this.client.session.promptAsync({ @@ -1552,13 +1471,10 @@ class OpenCodeAgentSession implements AgentSession { }); if (promptResponse.error) { - const errorMsg = normalizeTurnFailureError(promptResponse.error); - this.notifySubscribers({ - type: "turn_failed", - provider: "opencode", - error: errorMsg, - }); - throw new Error(errorMsg); + this.finishForegroundTurn( + { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(promptResponse.error) }, + turnId, + ); } } @@ -1576,12 +1492,13 @@ class OpenCodeAgentSession implements AgentSession { turnId: string, turnAbortController: AbortController, ): Promise { - const eventsResult = await this.client.event.subscribe({ - directory: this.config.cwd, - }); - try { - for await (const event of eventsResult.stream) { + const result = await this.client.event.subscribe( + { directory: this.config.cwd }, + { signal: turnAbortController.signal, sseMaxRetryAttempts: 0 }, + ); + + for await (const event of result.stream) { if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) { break; } @@ -1653,6 +1570,9 @@ class OpenCodeAgentSession implements AgentSession { this.runningToolCalls.clear(); } this.activeForegroundTurnId = null; + // Abort the SSE connection so the SDK tears down the underlying fetch. + this.abortController?.abort(); + this.abortController = null; this.notifySubscribers(event, turnId); } @@ -1708,16 +1628,12 @@ class OpenCodeAgentSession implements AgentSession { return; } - const messages = response.data; - - for (const message of messages) { - const { info, parts } = message; - const role = info.role as "user" | "assistant"; - - if (role === "user") { - // Extract user message text from parts - const textParts = parts.filter((p) => (p as { type?: string }).type === "text"); - const text = textParts.map((p) => (p as { text?: string }).text ?? "").join(""); + for (const { info, parts } of response.data) { + if (info.role === "user") { + const text = parts + .filter((p): p is Extract => p.type === "text") + .map((p) => p.text) + .join(""); if (text) { yield { @@ -1726,49 +1642,40 @@ class OpenCodeAgentSession implements AgentSession { item: { type: "user_message", text }, }; } - } else if (role === "assistant") { + } else { let emittedAssistantText = false; - // Process each part for (const part of parts) { - const partType = (part as { type?: string }).type; - - if (partType === "text") { - const text = (part as { text?: string }).text; - if (text) { + if (part.type === "text") { + if (part.text) { emittedAssistantText = true; yield { type: "timeline", provider: "opencode", - item: { type: "assistant_message", text }, + item: { type: "assistant_message", text: part.text }, }; } - } else if (partType === "reasoning") { - const text = (part as { text?: string }).text; - if (text) { + } else if (part.type === "reasoning") { + if (part.text) { yield { type: "timeline", provider: "opencode", - item: { type: "reasoning", text }, + item: { type: "reasoning", text: part.text }, }; } - } else if (partType === "tool") { + } else if (part.type === "tool") { const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); - if (parsedToolPart.success) { - if (parsedToolPart.data) { - yield { - type: "timeline", - provider: "opencode", - item: parsedToolPart.data, - }; - } + if (parsedToolPart.success && parsedToolPart.data) { + yield { + type: "timeline", + provider: "opencode", + item: parsedToolPart.data, + }; } } } if (!emittedAssistantText) { - const text = stringifyStructuredAssistantMessage( - (info as { structured?: unknown }).structured, - ); + const text = stringifyStructuredAssistantMessage(info.structured); if (text) { yield { type: "timeline", @@ -2027,7 +1934,7 @@ class OpenCodeAgentSession implements AgentSession { ); } - private translateEvent(event: unknown): AgentStreamEvent[] { + private translateEvent(event: OpenCodeEvent): AgentStreamEvent[] { const translated = translateOpenCodeEvent(event, { sessionId: this.sessionId, messageRoles: this.messageRoles, diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts index 7b3d99ad1..94d24f4d2 100644 --- a/packages/server/src/server/agent/providers/pi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts @@ -326,7 +326,7 @@ export class PiACPAgentClient extends ACPAgentClient { if (!existsSync(resolvedPiAcpPath)) { return false; } - if (!isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) { + if (!(await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi"))) { return false; } return ( @@ -340,8 +340,8 @@ export class PiACPAgentClient extends ACPAgentClient { async getDiagnostic(): Promise<{ diagnostic: string }> { try { const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi"; - const piCliPath = findExecutable(piCommand); - const piVersion = piCliPath ? resolveBinaryVersion(piCliPath) : "unknown"; + const piCliPath = await findExecutable(piCommand); + const piVersion = piCliPath ? await resolveBinaryVersion(piCliPath) : "unknown"; const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); const available = await this.isAvailable(); let modelsValue = "Not checked"; diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index 4e7265b4f..df9ea8953 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -7,7 +7,7 @@ import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; import dotenv from "dotenv"; -import { isCommandAvailable } from "../../utils/executable.js"; +import { isCommandAvailableSync } from "../../utils/executable.js"; // Load .env.test eagerly so isProviderAvailable() has credentials at collection time. const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -105,21 +105,21 @@ export function isProviderAvailable(provider: AgentProvider): boolean { switch (provider) { case "claude": return ( - isCommandAvailable("claude") && + isCommandAvailableSync("claude") && (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) ); case "codex": return ( - isCommandAvailable("codex") && + isCommandAvailableSync("codex") && (existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY)) ); case "copilot": - return isCommandAvailable("copilot"); + return isCommandAvailableSync("copilot"); case "opencode": - return isCommandAvailable("opencode"); + return isCommandAvailableSync("opencode"); case "pi": return ( - isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi") && + isCommandAvailableSync(process.env.PI_ACP_PI_COMMAND ?? "pi") && (Boolean(process.env.OPENAI_API_KEY) || Boolean(process.env.ANTHROPIC_API_KEY) || Boolean(process.env.OPENROUTER_API_KEY) || diff --git a/packages/server/src/server/editor-targets.test.ts b/packages/server/src/server/editor-targets.test.ts index 315ecab61..0b0f68a31 100644 --- a/packages/server/src/server/editor-targets.test.ts +++ b/packages/server/src/server/editor-targets.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; describe("editor-targets", () => { - it("lists available editors in deterministic order", () => { + it("lists available editors in deterministic order", async () => { const available = new Set(["code", "cursor", "explorer"]); - const editors = listAvailableEditorTargets({ + const editors = await listAvailableEditorTargets({ platform: "win32", findExecutable: (command) => (available.has(command) ? command : null), }); @@ -17,8 +17,8 @@ describe("editor-targets", () => { ]); }); - it("returns Finder on macOS", () => { - const editors = listAvailableEditorTargets({ + it("returns Finder on macOS", async () => { + const editors = await listAvailableEditorTargets({ platform: "darwin", findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null), }); @@ -26,8 +26,8 @@ describe("editor-targets", () => { expect(editors).toEqual([{ id: "finder", label: "Finder" }]); }); - it("returns the generic file manager target on Linux", () => { - const editors = listAvailableEditorTargets({ + it("returns the generic file manager target on Linux", async () => { + const editors = await listAvailableEditorTargets({ platform: "linux", findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null), }); diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts index 4bcadacf6..f5e388c0b 100644 --- a/packages/server/src/server/editor-targets.ts +++ b/packages/server/src/server/editor-targets.ts @@ -18,7 +18,7 @@ type EditorTargetDefinition = { type ListAvailableEditorTargetsDependencies = { platform?: NodeJS.Platform; - findExecutable?: (command: string) => string | null; + findExecutable?: (command: string) => string | null | Promise; }; type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & { @@ -65,28 +65,27 @@ function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDe return target; } -export function listAvailableEditorTargets( +export async function listAvailableEditorTargets( dependencies: ListAvailableEditorTargetsDependencies = {}, -): EditorTargetDescriptorPayload[] { +): Promise { const platform = dependencies.platform ?? process.platform; const findExecutableFn = dependencies.findExecutable ?? findExecutable; - return EDITOR_TARGETS.flatMap((target) => { + const results: EditorTargetDescriptorPayload[] = []; + for (const target of EDITOR_TARGETS) { if (!isTargetSupportedOnPlatform(target, platform)) { - return []; + continue; } - const executable = findExecutableFn(target.command); + const executable = await findExecutableFn(target.command); if (!executable) { - return []; + continue; } - - return [ - { - id: target.id, - label: target.label, - }, - ]; - }); + results.push({ + id: target.id, + label: target.label, + }); + } + return results; } type Launch = { @@ -94,17 +93,17 @@ type Launch = { args: string[]; }; -function resolveEditorLaunch(input: { +async function resolveEditorLaunch(input: { editorId: EditorTargetId; path: string; platform: NodeJS.Platform; - findExecutableFn: typeof findExecutable; -}): Launch { + findExecutableFn: (command: string) => string | null | Promise; +}): Promise { const target = resolveEditorTargetDefinition(input.editorId); if (!isTargetSupportedOnPlatform(target, input.platform)) { throw new Error(`Editor target unavailable: ${target.label}`); } - const executable = input.findExecutableFn(target.command); + const executable = await input.findExecutableFn(target.command); if (!executable) { throw new Error(`Editor target unavailable: ${target.label}`); } @@ -135,7 +134,7 @@ export async function openInEditorTarget( throw new Error(`Path does not exist: ${pathToOpen}`); } - const launch = resolveEditorLaunch({ + const launch = await resolveEditorLaunch({ editorId: input.editorId, path: pathToOpen, platform, diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index c617066d3..095afc138 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -33,7 +33,8 @@ export { export { applyProviderEnv, } from "./agent/provider-launch-config.js"; -export { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js"; +export { findExecutable, findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js"; +export { spawnProcess } from "../utils/spawn.js"; // Provider manifest (source of truth for provider definitions) export { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 37c50ea2c..eb71a098b 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,4 +1,5 @@ -import { exec, execFile, spawn } from "child_process"; +import { exec, execFile } from "child_process"; +import { spawnProcess } from "./spawn.js"; import { promisify } from "util"; import { resolve, dirname, basename } from "path"; import { realpathSync } from "fs"; @@ -87,7 +88,7 @@ async function spawnLimitedText(params: { const accept = new Set(params.acceptExitCodes ?? [0]); return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(params.cmd, params.args, { + const child = spawnProcess(params.cmd, params.args, { cwd: params.cwd, env: params.env, stdio: ["ignore", "pipe", "pipe"], @@ -106,7 +107,7 @@ async function spawnLimitedText(params: { } }; - child.stdout.on("data", (chunk: Buffer) => { + child.stdout!.on("data", (chunk: Buffer) => { if (truncated) return; stdoutBytes += chunk.length; if (stdoutBytes > params.maxBytes) { @@ -119,7 +120,7 @@ async function spawnLimitedText(params: { // We don't buffer stderr (it can be large too). Keep it minimal for debugging. let stderrPreview = ""; - child.stderr.on("data", (chunk: Buffer) => { + child.stderr!.on("data", (chunk: Buffer) => { if (stderrPreview.length > 2048) return; stderrPreview += chunk.toString("utf8"); }); @@ -1783,9 +1784,9 @@ export interface PullRequestStatusResult { githubFeaturesEnabled: boolean; } -function resolveGhPath(): string { +async function resolveGhPath(): Promise { if (cachedGhPath === undefined) { - cachedGhPath = findExecutable("gh"); + cachedGhPath = await findExecutable("gh"); } if (cachedGhPath === null) { throw new Error("GitHub CLI (gh) is not installed or not in PATH"); @@ -1859,7 +1860,7 @@ export async function createPullRequest( options: CreatePullRequestOptions, ): Promise<{ url: string; number: number }> { await requireGitRepo(cwd); - const ghPath = resolveGhPath(); + const ghPath = await resolveGhPath(); const repo = await resolveGitHubRepo(cwd); if (!repo) { throw new Error("Unable to determine GitHub repo from git remote"); @@ -1932,7 +1933,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise[1]>; +type FindExecutableDependencies = NonNullable[1]>; function createFindExecutableDependencies(): FindExecutableDependencies { return { @@ -22,57 +22,31 @@ beforeEach(() => { findExecutableDependencies = createFindExecutableDependencies(); }); -describe("findExecutable", () => { - test("on Windows, resolves executables using current machine and user PATH entries", () => { +describe("findExecutableSync", () => { + test("on Windows, resolves executables using where.exe with inherited PATH", () => { findExecutableDependencies.platform = vi.fn(() => "win32"); - process.env.Path = "C:\\Windows\\System32"; - findExecutableDependencies.execFileSync.mockImplementation( - ((command: string, args?: string[]) => { - if (command === "powershell") { - return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n"; - } - if (command === "where.exe") { - return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n"; - } - throw new Error(`unexpected command ${command}`); - }) as any, + findExecutableDependencies.execFileSync.mockReturnValue( + "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", ); - expect(findExecutable("claude", findExecutableDependencies)).toBe( + expect(findExecutableSync("claude", findExecutableDependencies)).toBe( "C:\\Users\\boudr\\.local\\bin\\claude.exe", ); - const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0]; - expect(powershellCall?.[0]).toBe("powershell"); - expect(powershellCall?.[1]).toContain("-NoProfile"); - expect(powershellCall?.[1]).toContain("-NonInteractive"); - expect(powershellCall?.[1]).toContain( - '$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }', - ); - const whereCall = findExecutableDependencies.execFileSync.mock.calls[1]; - expect(whereCall?.[0]).toBe("where.exe"); - expect(whereCall?.[1]).toEqual(["claude"]); - expect(whereCall?.[2]?.encoding).toBe("utf8"); - const env = whereCall?.[2]?.env as Record; - expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin"); - expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin"); + expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce(); + const call = findExecutableDependencies.execFileSync.mock.calls[0]; + expect(call?.[0]).toBe("where.exe"); + expect(call?.[1]).toEqual(["claude"]); + expect(call?.[2]?.encoding).toBe("utf8"); + expect(call?.[2]?.windowsHide).toBe(true); }); test("on Windows, preserves the first where.exe match", () => { findExecutableDependencies.platform = vi.fn(() => "win32"); - process.env.Path = "C:\\Windows\\System32"; - findExecutableDependencies.execFileSync.mockImplementation( - ((command: string) => { - if (command === "powershell") { - return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n"; - } - if (command === "where.exe") { - return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n"; - } - throw new Error(`unexpected command ${command}`); - }) as any, + findExecutableDependencies.execFileSync.mockReturnValue( + "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", ); - expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex"); + expect(findExecutableSync("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex"); }); test("on Unix, uses the last line from which output", () => { @@ -80,7 +54,7 @@ describe("findExecutable", () => { "/usr/local/bin/codex\n", ); - expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); + expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith( "which", ["codex"], @@ -92,7 +66,7 @@ describe("findExecutable", () => { findExecutableDependencies.execFileSync.mockReturnValue("codex\n"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(findExecutable("codex", findExecutableDependencies)).toBeNull(); + expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull(); expect(warnSpy).toHaveBeenCalledOnce(); warnSpy.mockRestore(); @@ -101,7 +75,7 @@ describe("findExecutable", () => { test("returns direct paths when they exist", () => { findExecutableDependencies.existsSync.mockReturnValue(true); - expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe( + expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe( "/usr/local/bin/codex", ); expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex"); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 57bf5162d..ad9b400b3 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -1,40 +1,17 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { platform } from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + export interface FindExecutableDependencies { execFileSync: typeof execFileSync; existsSync: typeof existsSync; platform: typeof platform; } -function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] { - try { - const output = deps.execFileSync( - "powershell", - [ - "-NoProfile", - "-NonInteractive", - "-Command", - [ - '$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")', - '$user = [Environment]::GetEnvironmentVariable("Path", "User")', - "if ($machine) { Write-Output $machine }", - "if ($user) { Write-Output $user }", - ].join("; "), - ], - { encoding: "utf8" }, - ); - return output - .split(/\r?\n/) - .flatMap((line) => line.split(";")) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - } catch { - return []; - } -} - function resolveExecutableFromWhichOutput( name: string, output: string, @@ -61,15 +38,13 @@ function resolveExecutableFromWhichOutput( } /** - * On Unix we use plain `which` — the daemon's process.env.PATH is enriched - * with the login shell environment at Electron desktop startup. + * On Unix we use `which`. On Windows we use `where.exe`. * - * On Windows we augment the daemon PATH with machine/user registry PATH values - * and return the first `where.exe` match. Launch-time execution decides whether - * the resolved path needs `cmd.exe` semantics (for example npm shims under - * nvm4w such as `C:\nvm4w\nodejs\codex`). + * Both rely on the inherited process.env.PATH — on macOS/Linux, Electron + * enriches it at startup via inheritLoginShellEnv(); on Windows, Electron + * inherits the full user environment from Explorer. */ -export function findExecutable( +export function findExecutableSync( name: string, dependencies?: FindExecutableDependencies, ): string | null { @@ -91,21 +66,10 @@ export function findExecutable( if (deps.platform() === "win32") { try { - const inheritedPath = process.env["Path"] ?? process.env["PATH"] ?? ""; - const resolvedPath = [ - ...inheritedPath.split(";"), - ...resolveWindowsPathEntries(deps), - ] - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .filter((entry, index, entries) => entries.indexOf(entry) === index) - .join(";"); - const env = { - ...process.env, - PATH: resolvedPath, - Path: resolvedPath, - }; - const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim(); + const out = deps.execFileSync("where.exe", [trimmed], { + encoding: "utf8", + windowsHide: true, + }).trim(); return ( out .split(/\r?\n/) @@ -128,8 +92,48 @@ export function findExecutable( } } -export function isCommandAvailable(command: string): boolean { - return findExecutable(command) !== null; +export function isCommandAvailableSync(command: string): boolean { + return findExecutableSync(command) !== null; +} + +export async function findExecutable(name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.includes("/") || trimmed.includes("\\")) { + return existsSync(trimmed) ? trimmed : null; + } + + if (platform() === "win32") { + try { + const { stdout } = await execFileAsync("where.exe", [trimmed], { + encoding: "utf8", + windowsHide: true, + }); + return ( + stdout + .trim() + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? null + ); + } catch { + return null; + } + } + + try { + const { stdout } = await execFileAsync("which", [trimmed], { encoding: "utf8" }); + return resolveExecutableFromWhichOutput(trimmed, stdout.trim(), "which"); + } catch { + return null; + } +} + +export async function isCommandAvailable(command: string): Promise { + return (await findExecutable(command)) !== null; } /** diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts new file mode 100644 index 000000000..21898b8cb --- /dev/null +++ b/packages/server/src/utils/spawn.ts @@ -0,0 +1,42 @@ +import { + spawn, + type ChildProcess, + type SpawnOptions, +} from "node:child_process"; + +/** + * Platform-aware spawn that centralizes Windows shell and quoting concerns. + * + * On Windows: + * - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell` + * - Quotes the command and arguments so paths with spaces survive cmd.exe parsing + * - Always sets `windowsHide: true` to prevent console window flashes + * + * On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`). + */ +export function spawnProcess( + command: string, + args: string[], + options?: SpawnOptions, +): ChildProcess { + const isWindows = process.platform === "win32"; + + const resolvedCommand = isWindows ? quoteForCmd(command) : command; + const resolvedArgs = isWindows ? args.map(quoteForCmd) : args; + + return spawn(resolvedCommand, resolvedArgs, { + ...options, + shell: options?.shell ?? isWindows, + windowsHide: true, + }); +} + +/** + * Quote a string for cmd.exe if it contains spaces and isn't already quoted. + * No-op for strings without spaces or strings that are already double-quoted. + */ +function quoteForCmd(value: string): string { + if (!value.includes(" ")) return value; + if (value.startsWith('"') && value.endsWith('"')) return value; + return `"${value}"`; +} From 8ff94dc03ed25db3ecf64b7a1bcc1605041093d6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 8 Apr 2026 23:36:07 +0700 Subject: [PATCH 10/32] fix(app): improve pair device screen with read-only input for link Replace truncating text with a read-only TextInput + copy button pattern so the pairing URL doesn't get cut off. Increase QR code size from 200x200 to 320x320. --- .../components/pair-device-section.tsx | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 packages/app/src/desktop/components/pair-device-section.tsx diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx new file mode 100644 index 000000000..ee63a78c6 --- /dev/null +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -0,0 +1,187 @@ +import { useCallback } from "react"; +import { ActivityIndicator, Image, Text, TextInput, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import * as QRCode from "qrcode"; +import { useQuery } from "@tanstack/react-query"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { RotateCw, Copy, Check } from "lucide-react-native"; +import { settingsStyles } from "@/styles/settings"; +import { Button } from "@/components/ui/button"; +import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { useState } from "react"; + +export function PairDeviceSection() { + const { theme } = useUnistyles(); + const showSection = shouldUseDesktopDaemon(); + const [copied, setCopied] = useState(false); + + const pairingQuery = useQuery({ + queryKey: ["desktop-daemon-pairing"], + queryFn: getDesktopDaemonPairing, + enabled: showSection, + staleTime: 5 * 60 * 1000, + retry: 1, + }); + + const qrQuery = useQuery({ + queryKey: ["desktop-daemon-pairing-qr", pairingQuery.data?.url], + queryFn: () => + QRCode.toDataURL(pairingQuery.data!.url!, { + errorCorrectionLevel: "M", + margin: 1, + width: 480, + }), + enabled: !!pairingQuery.data?.url, + staleTime: Infinity, + }); + + const handleCopyLink = useCallback(async () => { + if (!pairingQuery.data?.url) return; + await Clipboard.setStringAsync(pairingQuery.data.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [pairingQuery.data?.url]); + + if (!showSection) return null; + + return ( + + Pair device + + {pairingQuery.isPending ? ( + + + Loading pairing offer… + + ) : pairingQuery.isError ? ( + + + {pairingQuery.error instanceof Error + ? pairingQuery.error.message + : "Failed to load pairing offer."} + + + + ) : !pairingQuery.data?.url ? ( + + + {pairingQuery.data?.relayEnabled === false + ? "Relay is not enabled. Enable relay to pair a device." + : "Pairing offer unavailable."} + + + + ) : ( + + + Scan this QR code with Paseo on your phone, or copy the link below. + + + {qrQuery.data ? ( + + ) : qrQuery.isError ? ( + QR code unavailable. + ) : ( + + )} + + + + + + + + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + centered: { + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[6], + paddingHorizontal: theme.spacing[4], + }, + content: { + gap: theme.spacing[3], + padding: theme.spacing[4], + }, + hint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + textAlign: "center", + }, + qrContainer: { + alignItems: "center", + justifyContent: "center", + alignSelf: "center", + width: 320, + height: 320, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface0, + padding: theme.spacing[2], + }, + qrImage: { + width: "100%", + height: "100%", + }, + linkRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + inputWrapper: { + flex: 1, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface0, + overflow: "hidden", + }, + linkInput: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + outlineStyle: "none", + } as any, +})); From ffcc35485d1b5e3f5590bda088045b44d3d24427 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 00:24:58 +0700 Subject: [PATCH 11/32] Fix agent follow-up turns and wait lifecycle --- .../server/src/server/agent/agent-manager.ts | 20 +++-- .../src/server/agent/agent-sdk-types.ts | 13 +++- .../src/server/agent/providers/acp-agent.ts | 2 +- .../providers/codex-app-server-agent.test.ts | 65 ++-------------- .../agent/providers/codex-app-server-agent.ts | 29 ++++--- packages/server/src/server/session.ts | 77 ++++++++++--------- 6 files changed, 88 insertions(+), 118 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index cd84027ec..53d2fe352 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -17,6 +17,7 @@ import type { AgentMode, AgentPermissionRequest, AgentPermissionResponse, + AgentPermissionResult, AgentPersistenceHandle, AgentPromptInput, AgentProvider, @@ -1156,11 +1157,10 @@ export class AgentManager { agent.lastError = undefined; const self = this; + const pendingRun = self.createPendingForegroundRun(); + self.pendingForegroundRuns.set(agentId, pendingRun); const streamForwarder = (async function* streamForwarder() { - const pendingRun = self.createPendingForegroundRun(); - self.pendingForegroundRuns.set(agentId, pendingRun); - let turnId: string; let waiter: ForegroundTurnWaiter | null = null; try { @@ -1440,12 +1440,12 @@ export class AgentManager { agentId: string, requestId: string, response: AgentPermissionResponse, - ): Promise { + ): Promise { const agent = this.requireAgent(agentId); agent.inFlightPermissionResponses.add(requestId); try { - await agent.session.respondToPermission(requestId, response); + const result = await agent.session.respondToPermission(requestId, response); agent.pendingPermissions.delete(requestId); try { @@ -1463,6 +1463,8 @@ export class AgentManager { agent.bufferedPermissionResolutions.delete(requestId); this.dispatchStream(agent.id, bufferedResolution); } + + return result; } finally { agent.inFlightPermissionResponses.delete(requestId); agent.bufferedPermissionResolutions.delete(requestId); @@ -1625,8 +1627,9 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found`); } + const pendingForegroundRun = this.getPendingForegroundRun(agentId); const hasForegroundTurn = - Boolean(snapshot.activeForegroundTurnId) || this.hasPendingForegroundRun(agentId); + Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun); const immediatePermission = this.peekPendingPermission(snapshot); if (immediatePermission) { @@ -1668,7 +1671,10 @@ export class AgentManager { } let currentStatus: AgentLifecycleStatus = initialStatus; - let hasStarted = initialBusy || hasForegroundTurn; + let hasStarted = + isAgentBusy(initialStatus) || + Boolean(snapshot.activeForegroundTurnId) || + Boolean(pendingForegroundRun?.started); let terminalStatusOverride: AgentLifecycleStatus | null = null; // Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index cc0850cc1..6465a5ab8 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -441,6 +441,14 @@ export interface AgentLaunchContext { env?: Record; } +/** + * Returned by respondToPermission when the permission resolution requires + * a follow-up turn (e.g. Codex plan approval → implementation). + */ +export interface AgentPermissionResult { + followUpPrompt?: AgentPromptInput; +} + export interface AgentSession { readonly provider: AgentProvider; readonly id: string | null; @@ -455,7 +463,10 @@ export interface AgentSession { getCurrentMode(): Promise; setMode(modeId: string): Promise; getPendingPermissions(): AgentPermissionRequest[]; - respondToPermission(requestId: string, response: AgentPermissionResponse): Promise; + respondToPermission( + requestId: string, + response: AgentPermissionResponse, + ): Promise; describePersistence(): AgentPersistenceHandle | null; interrupt(): Promise; close(): Promise; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 0db635aac..f0a90b0ac 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -466,7 +466,7 @@ export class ACPAgentClient implements AgentClient { async isAvailable(): Promise { try { - this.resolveLaunchCommand(); + await this.resolveLaunchCommand(); return true; } catch { return false; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 0cd41a189..66a72c7b8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -623,17 +623,13 @@ describe("Codex app-server provider", () => { }); }); - test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => { + test("approving a synthetic Codex plan permission disables plan and fast mode and returns follow-up prompt", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - const startTurnSpy = vi - .spyOn(session, "startTurn") - .mockResolvedValue({ turnId: "follow-up-turn" }); - (session as any).handleNotification("turn/started", { turn: { id: "turn-plan-2" }, }); @@ -653,7 +649,7 @@ describe("Codex app-server provider", () => { throw new Error("Expected synthetic plan approval permission"); } - await session.respondToPermission(request.request.id, { + const result = await session.respondToPermission(request.request.id, { behavior: "allow", selectedActionId: "implement", }); @@ -664,7 +660,10 @@ describe("Codex app-server provider", () => { plan_mode: false, fast_mode: false, }); - expect(startTurnSpy).toHaveBeenCalledWith( + // The session returns the follow-up prompt instead of calling startTurn directly. + // The caller (session/agent-manager) is responsible for sending it through streamAgent. + expect(result).toBeDefined(); + expect(result!.followUpPrompt).toEqual( expect.stringContaining("The user approved the plan. Implement it now."), ); expect(events.at(-1)).toEqual({ @@ -677,56 +676,4 @@ describe("Codex app-server provider", () => { }, }); }); - - test("failed synthetic Codex plan implementation keeps the permission pending for retry", async () => { - const session = createSession({ - featureValues: { plan_mode: true, fast_mode: true }, - }); - const events: AgentStreamEvent[] = []; - session.subscribe((event) => events.push(event)); - - const startTurnSpy = vi - .spyOn(session, "startTurn") - .mockRejectedValueOnce(new Error("follow-up failed")); - - (session as any).handleNotification("turn/started", { - turn: { id: "turn-plan-retry" }, - }); - (session as any).handleNotification("turn/plan/updated", { - plan: [{ step: "Implement the retriable flow", status: "pending" }], - }); - (session as any).handleNotification("turn/completed", { - turn: { status: "completed", error: null }, - }); - - const request = events.find( - (event): event is Extract => - event.type === "permission_requested" && event.request.kind === "plan", - ); - expect(request).toBeDefined(); - if (!request) { - throw new Error("Expected synthetic plan approval permission"); - } - - await expect( - session.respondToPermission(request.request.id, { - behavior: "allow", - selectedActionId: "implement", - }), - ).rejects.toThrow("follow-up failed"); - - expect(startTurnSpy).toHaveBeenCalledTimes(1); - expect((session as any).planModeEnabled).toBe(true); - expect((session as any).config.featureValues).toEqual({ - plan_mode: true, - fast_mode: true, - }); - expect(session.getPendingPermissions()).toEqual([request.request]); - expect( - events.some( - (event) => - event.type === "permission_resolved" && event.requestId === request.request.id, - ), - ).toBe(false); - }); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index a6a59d74e..2e3072e60 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -9,6 +9,7 @@ import type { McpServerConfig, AgentPermissionRequest, AgentPermissionResponse, + AgentPermissionResult, AgentPromptContentBlock, AgentPromptInput, AgentRunOptions, @@ -2673,22 +2674,19 @@ class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); } - private async handleApprovedPlanPermission(params: { planText?: unknown }): Promise { + /** + * Prepare the session for plan implementation by disabling plan/fast mode + * and returning the implementation prompt. The caller is responsible for + * starting the turn through the normal streamAgent path. + */ + private preparePlanImplementation(params: { planText?: unknown }): string { const planText = typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : ""; - const previousPlanMode = this.planModeEnabled; - const previousFastMode = this.serviceTier === "fast"; this.applyFeatureValue("plan_mode", false); this.applyFeatureValue("fast_mode", false); - try { - await this.startTurn(buildCodexPlanImplementationPrompt(planText)); - } catch (error) { - this.applyFeatureValue("plan_mode", previousPlanMode); - this.applyFeatureValue("fast_mode", previousFastMode); - throw error; - } + return buildCodexPlanImplementationPrompt(planText); } private registerRequestHandlers(): void { @@ -3102,7 +3100,10 @@ class CodexAppServerAgentSession implements AgentSession { return Array.from(this.pendingPermissions.values()); } - async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + async respondToPermission( + requestId: string, + response: AgentPermissionResponse, + ): Promise { const pending = this.pendingPermissionHandlers.get(requestId); if (!pending) { throw new Error(`No pending Codex app-server permission request with id '${requestId}'`); @@ -3110,8 +3111,9 @@ class CodexAppServerAgentSession implements AgentSession { const pendingRequest = this.pendingPermissions.get(requestId) ?? null; if (pending.kind === "plan") { + let followUpPrompt: string | undefined; if (response.behavior === "allow") { - await this.handleApprovedPlanPermission({ + followUpPrompt = this.preparePlanImplementation({ planText: pending.planText ?? pendingRequest?.metadata?.planText, }); } @@ -3125,6 +3127,9 @@ class CodexAppServerAgentSession implements AgentSession { requestId, resolution: response, }); + if (followUpPrompt) { + return { followUpPrompt }; + } return; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b60151273..80bf24cbb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2836,7 +2836,7 @@ export class Session { images?: Array<{ data: string; mimeType: string }>, runOptions?: AgentRunOptions, options?: { spokenInput?: boolean }, - ): Promise { + ): Promise<{ ok: true } | { ok: false; error: string }> { this.sessionLogger.info( { agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 }, `Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""}`, @@ -2848,7 +2848,10 @@ export class Session { await this.ensureAgentLoaded(agentId); } catch (error) { this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt"); - return; + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; } try { @@ -2866,7 +2869,7 @@ export class Session { const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; const prompt = this.buildAgentPrompt(promptText, images); - this.startAgentStream(agentId, prompt, runOptions); + return this.startAgentStream(agentId, prompt, runOptions); } /** @@ -2914,6 +2917,29 @@ export class Session { const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels }); await this.forwardAgentUpdate(snapshot); + if (trimmedPrompt) { + scheduleAgentMetadataGeneration({ + agentManager: this.agentManager, + agentId: snapshot.id, + cwd: snapshot.cwd, + initialPrompt: trimmedPrompt, + explicitTitle, + paseoHome: this.paseoHome, + logger: this.sessionLogger, + }); + + const started = await this.handleSendAgentMessage( + snapshot.id, + trimmedPrompt, + resolveClientMessageId(clientMessageId), + images, + outputSchema ? { outputSchema } : undefined, + ); + if (!started.ok) { + throw new Error(started.error); + } + } + if (requestId) { const agentPayload = await this.getAgentPayloadById(snapshot.id); if (!agentPayload) { @@ -2930,40 +2956,6 @@ export class Session { }); } - if (trimmedPrompt) { - scheduleAgentMetadataGeneration({ - agentManager: this.agentManager, - agentId: snapshot.id, - cwd: snapshot.cwd, - initialPrompt: trimmedPrompt, - explicitTitle, - paseoHome: this.paseoHome, - logger: this.sessionLogger, - }); - - void this.handleSendAgentMessage( - snapshot.id, - trimmedPrompt, - resolveClientMessageId(clientMessageId), - images, - outputSchema ? { outputSchema } : undefined, - ).catch((promptError) => { - this.sessionLogger.error( - { err: promptError, agentId: snapshot.id }, - `Failed to run initial prompt for agent ${snapshot.id}`, - ); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`, - }, - }); - }); - } - if (worktreeConfig) { void runAsyncWorktreeBootstrap({ agentId: snapshot.id, @@ -3924,8 +3916,16 @@ export class Session { ); try { - await this.agentManager.respondToPermission(agentId, requestId, response); + const result = await this.agentManager.respondToPermission(agentId, requestId, response); this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`); + + if (result?.followUpPrompt) { + this.sessionLogger.debug( + { agentId }, + "Permission response requires follow-up turn, starting agent stream", + ); + this.startAgentStream(agentId, result.followUpPrompt); + } } catch (error: any) { this.sessionLogger.error( { err: error, agentId, requestId }, @@ -6602,6 +6602,7 @@ export class Session { try { let result = await this.agentManager.waitForAgentEvent(agentId, { signal: abortController.signal, + waitForActive: true, }); let final = await this.getAgentPayloadById(agentId); if (!final) { From d39414064ee136a5ea7940c1fc8e979cfb7f45d4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 00:25:30 +0700 Subject: [PATCH 12/32] Fix opencode terminal states and model refresh --- ...code-agent.error-handling.real.e2e.test.ts | 188 ++++++++++++++++++ ...opencode-agent.list-models-timeout.test.ts | 69 +++++++ .../server/agent/providers/opencode-agent.ts | 72 ++++++- ...ncode-initial-prompt-wait.real.e2e.test.ts | 113 +++++++++++ .../opencode-invalid-model.real.e2e.test.ts | 61 ++++++ 5 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts create mode 100644 packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts new file mode 100644 index 000000000..d59ea9831 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "vitest"; +import pino from "pino"; + +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; + +function isTerminalEvent(event: AgentStreamEvent): boolean { + return ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ); +} + +/** + * Real e2e tests for OpenCode error handling. + * + * Validates that OpenCode surfaces errors properly instead of hanging forever + * when models are invalid, auth fails, or provider API calls fail. + */ +describe("opencode agent error handling (real)", () => { + test.runIf(isCommandAvailableSync("opencode"))( + "surfaces error for the exact opencode/ path used by paseo run", + async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("opencode/adklasldkdas"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + } finally { + await session.close().catch(() => undefined); + } + }, + 45_000, + ); + + test.runIf(isCommandAvailableSync("opencode"))( + "surfaces error for unknown provider model (fast path)", + async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("bogus-provider/totally-fake-model-12345"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + } finally { + await session.close().catch(() => undefined); + } + }, + 30_000, + ); + + test.runIf(isCommandAvailableSync("opencode"))( + "sequential sessions: second session works after first errors", + async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + + // Session 1: bogus model, will error quickly + const s1 = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + await s1.setModel("bogus-provider/fake-model-12345"); + for await (const event of streamSession(s1, "Say hello")) { + if (isTerminalEvent(event)) break; + } + await s1.close(); + + // Session 2: different bogus model, should also work + const s2 = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + await s2.setModel("bogus-provider/fake-model-67890"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(s2, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + await s2.close().catch(() => undefined); + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + }, + 30_000, + ); + + test.runIf(isCommandAvailableSync("opencode"))( + "surfaces error for known provider with nonexistent model (retry path)", + async () => { + // When the provider is recognized (anthropic) but the model doesn't exist, + // OpenCode retries before surfacing the error. This must not hang. + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("anthropic/claude-nonexistent-99"); + + const events: AgentStreamEvent[] = []; + const start = Date.now(); + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + const elapsed = Date.now() - start; + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(elapsed).toBeLessThan(30_000); + console.log( + `[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`, + ); + } finally { + await session.close().catch(() => undefined); + } + }, + 45_000, + ); + + test.runIf(isCommandAvailableSync("opencode"))( + "surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", + async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("zai/glm-5.1"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + expect( + (terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase(), + ).toMatch(/insufficient balance|resource package|recharge/); + } finally { + await session.close().catch(() => undefined); + } + }, + 45_000, + ); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts new file mode 100644 index 000000000..0e0a7f362 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("@opencode-ai/sdk/v2/client", () => ({ + createOpencodeClient: vi.fn(), +})); + +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js"; + +describe("OpenCodeAgentClient.listModels timeout", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => { + vi.useFakeTimers(); + + const providerList = vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve({ + data: { + connected: ["zai"], + all: [ + { + id: "zai", + name: "Z.AI", + models: { + "glm-5.1": { + name: "GLM 5.1", + limit: { context: 128_000 }, + }, + }, + }, + ], + }, + }); + }, 15_000); + }), + ); + + vi.mocked(createOpencodeClient).mockReturnValue({ + provider: { + list: providerList, + }, + } as never); + + vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ + ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }), + } as never); + + const client = new OpenCodeAgentClient(createTestLogger()); + const modelsPromise = client.listModels(); + + await vi.advanceTimersByTimeAsync(15_000); + + await expect(modelsPromise).resolves.toMatchObject([ + { + provider: "opencode", + id: "zai/glm-5.1", + label: "GLM 5.1", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 9ac9eba51..126003918 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -93,6 +93,19 @@ type OpenCodeMcpConfig = }; const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const; +const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000; +const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [ + "insufficient balance", + "no resource package", + "please recharge", + "invalid api key", + "unauthorized", + "authentication", + "model not found", + "unknown model", + "does not exist", + "unsupported model", +] as const; const OpencodeToolStateSchema = z .object({ @@ -217,6 +230,14 @@ function normalizeTurnFailureError(error: unknown): string { return normalized.length > 0 ? normalized : "Unknown error"; } +function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean { + const normalized = typeof message === "string" ? message.trim().toLowerCase() : ""; + if (!normalized) { + return false; + } + return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token)); +} + function isAlreadyPresentMcpError(error: unknown): boolean { const normalized = stringifyUnknownError(error).toLowerCase(); return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token)); @@ -726,16 +747,17 @@ export class OpenCodeAgentClient implements AgentClient { directory: options?.cwd ?? process.cwd(), }); - // Set a timeout for the API call to fail fast if OpenCode isn't responding + // Background model discovery can be legitimately slow while OpenCode refreshes + // provider state, so allow longer than turn execution paths. const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject( new Error( - "OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers", + `OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`, ), ), - 10_000, + OPENCODE_PROVIDER_LIST_TIMEOUT_MS, ); }); @@ -1215,6 +1237,14 @@ export function translateOpenCodeEvent( provider: "opencode", usage: undefined, }); + } else if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) { + state.streamedPartKeys.clear(); + state.partTypes.clear(); + events.push({ + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(status.message), + }); } // "retry" and "busy" are transient — no terminal event. break; @@ -1452,7 +1482,7 @@ class OpenCodeAgentSession implements AgentSession { ); }); } else { - const promptResponse = await this.client.session.promptAsync({ + void this.client.session.promptAsync({ sessionID: this.sessionId, directory: this.config.cwd, parts, @@ -1468,14 +1498,27 @@ class OpenCodeAgentSession implements AgentSession { ...(model ? { model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), - }); - - if (promptResponse.error) { + }).then((promptResponse) => { + if (promptResponse.error) { + this.finishForegroundTurn( + { + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(promptResponse.error), + }, + turnId, + ); + } + }).catch((error) => { this.finishForegroundTurn( - { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(promptResponse.error) }, + { + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(error), + }, turnId, ); - } + }); } return { turnId }; @@ -1529,6 +1572,17 @@ class OpenCodeAgentSession implements AgentSession { this.notifySubscribers(e, turnId); } } + + if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { + this.finishForegroundTurn( + { + type: "turn_failed", + provider: "opencode", + error: "OpenCode event stream ended before the turn reached a terminal state", + }, + turnId, + ); + } } catch (error) { if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { this.finishForegroundTurn( diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts new file mode 100644 index 000000000..d8213d660 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { isProviderAvailable } from "./agent-configs.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-")); +} + +async function createHarness(): Promise<{ + client: DaemonClient; + daemon: Awaited>; +}> { + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { opencode: new OpenCodeAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "opencode-init-prompt" } }); + return { client, daemon }; +} + +describe("daemon E2E (real opencode) - initial prompt wait", () => { + test.runIf(isProviderAvailable("opencode"))( + "waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", + async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + + try { + const models = await client.listProviderModels("opencode"); + expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode initial prompt wait regression", + model: "opencode/big-pickle", + initialPrompt: "Reply with exactly: BIG_PICKLE_OK", + }); + + const finish = await client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("idle"); + expect(finish.lastMessage).toContain("BIG_PICKLE_OK"); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("idle"); + + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const assistantMessages = timeline.entries.filter( + (entry) => entry.item.type === "assistant_message", + ); + + expect(assistantMessages.length).toBeGreaterThan(0); + expect( + assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK")), + ).toBe(true); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 90_000, + ); + + test.runIf(isProviderAvailable("opencode"))( + "waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", + async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + + try { + const models = await client.listProviderModels("opencode"); + expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode zai fatal retry regression", + model: "zai/glm-5.1", + initialPrompt: "Reply with exactly: GLM_51_OK", + }); + + const finish = await client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("error"); + expect((finish.error ?? "").toLowerCase()).toMatch( + /insufficient balance|resource package|recharge/, + ); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("error"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 90_000, + ); +}); diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts new file mode 100644 index 000000000..903665674 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { isProviderAvailable } from "./agent-configs.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-invalid-model-")); +} + +async function createHarness(): Promise<{ + client: DaemonClient; + daemon: Awaited>; +}> { + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { opencode: new OpenCodeAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-model" } }); + return { client, daemon }; +} + +describe("daemon E2E (real opencode) - invalid model handling", () => { + test.runIf(isProviderAvailable("opencode"))( + "initial prompt with a nonexistent OpenCode model fails instead of hanging forever", + async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + + try { + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode invalid model regression", + model: "opencode/adklasldkdas", + initialPrompt: "hello", + }); + + const finish = await client.waitForFinish(agent.id, 30_000); + expect(finish.status).toBe("error"); + expect(finish.error).toBeTruthy(); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("error"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); From 6bf8da8087cb41c48148909130a7239a7f460ada Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 00:27:20 +0700 Subject: [PATCH 13/32] Commit current desktop and CLI diff --- .../components/desktop-updates-section.tsx | 213 +----------------- packages/app/src/screens/settings-screen.tsx | 8 +- packages/cli/src/commands/daemon/status.ts | 34 +-- .../tests/e2e/opencode-invalid-model.test.ts | 72 ++++++ packages/desktop/src/daemon/daemon-manager.ts | 18 +- packages/desktop/src/daemon/runtime-paths.ts | 10 +- .../src/integrations/integrations-manager.ts | 15 +- 7 files changed, 126 insertions(+), 244 deletions(-) create mode 100644 packages/cli/tests/e2e/opencode-invalid-model.test.ts diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index df0756eff..ef8be49ab 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Alert, Image, Text, View } from "react-native"; +import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; -import * as QRCode from "qrcode"; import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; @@ -12,7 +11,6 @@ import { RotateCw, Copy, FileText, - Smartphone, Activity, } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; @@ -24,7 +22,6 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, getDesktopDaemonLogs, - getDesktopDaemonPairing, getDesktopDaemonStatus, restartDesktopDaemon, shouldUseDesktopDaemon, @@ -32,7 +29,6 @@ import { stopDesktopDaemon, type DesktopDaemonLogs, type DesktopDaemonStatus, - type DesktopPairingOffer, } from "@/desktop/daemon/desktop-daemon"; export interface LocalDaemonSectionProps { @@ -52,10 +48,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const [statusMessage, setStatusMessage] = useState(null); const [daemonLogs, setDaemonLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); - const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); - const [isLoadingPairing, setIsLoadingPairing] = useState(false); - const [pairingOffer, setPairingOffer] = useState(null); - const [pairingStatusMessage, setPairingStatusMessage] = useState(null); const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); @@ -238,46 +230,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD setIsLogsModalOpen(true); }, [daemonLogs]); - const handleOpenPairingModal = useCallback(() => { - if (isLoadingPairing) { - return; - } - - setIsPairingModalOpen(true); - setIsLoadingPairing(true); - setPairingStatusMessage(null); - - void getDesktopDaemonPairing() - .then((pairing) => { - setPairingOffer(pairing); - if (!pairing.relayEnabled || !pairing.url) { - setPairingStatusMessage("Relay pairing is not available."); - } - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setPairingOffer(null); - setPairingStatusMessage(`Unable to load pairing offer: ${message}`); - }) - .finally(() => { - setIsLoadingPairing(false); - }); - }, [isLoadingPairing]); - - const handleCopyPairingLink = useCallback(() => { - if (!pairingOffer?.url) { - return; - } - void Clipboard.setStringAsync(pairingOffer.url) - .then(() => { - Alert.alert("Copied", "Pairing link copied."); - }) - .catch((error) => { - console.error("[Settings] Failed to copy pairing link", error); - Alert.alert("Error", "Unable to copy pairing link."); - }); - }, [pairingOffer?.url]); - const handleOpenCliStatus = useCallback(async () => { setIsLoadingCliStatus(true); try { @@ -418,20 +370,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD - - - Pair device - Connect your phone to this computer. - - - Full status @@ -460,20 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD ) : null} - setIsPairingModalOpen(false)} - title="Pair device" - testID="managed-daemon-pairing-dialog" - > - - - setIsLogsModalOpen(false)} @@ -516,107 +440,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration"; -function PairingOfferDialogContent(input: { - isLoading: boolean; - pairingOffer: DesktopPairingOffer | null; - statusMessage: string | null; - onCopyLink: () => void; -}) { - const { isLoading, pairingOffer, statusMessage, onCopyLink } = input; - const [qrDataUrl, setQrDataUrl] = useState(null); - const [qrError, setQrError] = useState(null); - - useEffect(() => { - let cancelled = false; - - if (!pairingOffer?.url) { - setQrDataUrl(null); - setQrError(null); - return () => { - cancelled = true; - }; - } - - setQrError(null); - setQrDataUrl(null); - - void QRCode.toDataURL(pairingOffer.url, { - errorCorrectionLevel: "M", - margin: 1, - width: 480, - }) - .then((dataUrl) => { - if (cancelled) { - return; - } - setQrDataUrl(dataUrl); - }) - .catch((error) => { - if (cancelled) { - return; - } - setQrError(error instanceof Error ? error.message : String(error)); - }); - - return () => { - cancelled = true; - }; - }, [pairingOffer?.url]); - - if (isLoading) { - return ( - - - Loading pairing offer… - - ); - } - - if (statusMessage) { - return ( - - {statusMessage} - - ); - } - - if (!pairingOffer?.url) { - return ( - - Pairing offer unavailable. - - ); - } - - return ( - - - Scan this QR code in Paseo, or copy the pairing link below. - - - {qrDataUrl ? ( - - ) : qrError ? ( - QR unavailable: {qrError} - ) : ( - - )} - - - Pairing link - - {pairingOffer.url} - - - - - - - ); -} - const styles = StyleSheet.create((theme) => ({ actionGroup: { flexDirection: "row", @@ -659,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[3], paddingBottom: theme.spacing[2], }, - pairingState: { - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[6], - }, - qrCard: { - alignItems: "center", - justifyContent: "center", - width: "100%", - aspectRatio: 1, - alignSelf: "stretch", - padding: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface0, - }, - qrImage: { - width: "100%", - height: "100%", - }, - linkSection: { - gap: theme.spacing[2], - }, - linkLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - linkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - lineHeight: 18, - }, logOutput: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 6227b429c..1246ec6ac 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -22,6 +22,7 @@ import { Shield, Puzzle, Blocks, + Smartphone, } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; @@ -54,6 +55,7 @@ import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-mod import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { PairDeviceSection } from "@/desktop/components/pair-device-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -82,7 +84,8 @@ type SettingsSectionId = | "diagnostics" | "about" | "permissions" - | "daemon"; + | "daemon" + | "pair-device"; interface SettingsSectionDef { id: SettingsSectionId; @@ -101,6 +104,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio if (context.isDesktopApp) { sections.push( { id: "integrations", label: "Integrations", icon: Puzzle }, + { id: "pair-device", label: "Pair device", icon: Smartphone }, { id: "daemon", label: "Daemon", icon: Settings }, ); } @@ -647,6 +651,8 @@ function SettingsSectionContent({ return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : null; + case "pair-device": + return isDesktopApp ? : null; case "daemon": return isDesktopApp ? ( diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 1029b53d3..aa3ec6cc1 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,12 +1,14 @@ import type { Command } from "commander"; -import { execFileSync } from "node:child_process"; +import { execFile } from "node:child_process"; import { createRequire } from "node:module"; +import { promisify } from "node:util"; import { getOrCreateServerId, findExecutable, - quoteWindowsCommand, applyProviderEnv, } from "@getpaseo/server"; + +const execFileAsync = promisify(execFile); import { tryConnectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; @@ -171,31 +173,33 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [ { label: "OpenCode", binary: "opencode" }, ]; -function checkProviderBinary(binary: string): { path: string | null; version: string | null } { - const binaryPath = findExecutable(binary); +async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> { + const binaryPath = await findExecutable(binary); if (!binaryPath) { return { path: null, version: null }; } const env = applyProviderEnv(process.env); try { - const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], { + const { stdout } = await execFileAsync(binaryPath, ["--version"], { encoding: "utf8", timeout: 5000, - stdio: ["ignore", "pipe", "pipe"], env, - shell: process.platform === "win32", - }).trim(); - return { path: binaryPath, version: output || null }; + windowsHide: true, + }); + return { path: binaryPath, version: stdout.trim() || null }; } catch { return { path: binaryPath, version: null }; } } -function checkProviderBinaries(): ProviderBinaryStatus[] { - return PROVIDER_BINARIES.map(({ label, binary }) => { - const result = checkProviderBinary(binary); - return { label, ...result }; - }); +async function checkProviderBinaries(): Promise { + const results = await Promise.all( + PROVIDER_BINARIES.map(async ({ label, binary }) => { + const result = await checkProviderBinary(binary); + return { label, ...result }; + }), + ); + return results; } function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null { @@ -295,7 +299,7 @@ export async function runStatusCommand( note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`); } - const providers = checkProviderBinaries(); + const providers = await checkProviderBinaries(); const daemonStatus: DaemonStatus = { serverId, diff --git a/packages/cli/tests/e2e/opencode-invalid-model.test.ts b/packages/cli/tests/e2e/opencode-invalid-model.test.ts new file mode 100644 index 000000000..ef047ac15 --- /dev/null +++ b/packages/cli/tests/e2e/opencode-invalid-model.test.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts"; + +interface E2EContext extends TestDaemonContext { + paseo: ( + args: string[], + opts?: { timeout?: number; cwd?: string }, + ) => Promise<{ + exitCode: number; + stdout: string; + stderr: string; + }>; +} + +let ctx: E2EContext; + +async function setup(): Promise { + ctx = await createE2ETestContext({ timeout: 45_000 }); +} + +async function cleanup(): Promise { + if (ctx) { + await ctx.stop(); + } +} + +async function test_invalid_opencode_model_does_not_report_completed_while_still_running() { + const result = await ctx.paseo( + ["run", "--provider", "opencode/adklasldkdas", "hello"], + { timeout: 45_000 }, + ); + + const output = `${result.stdout}\n${result.stderr}`; + const agentId = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0]; + + if (result.exitCode !== 0) { + assert( + output.toLowerCase().includes("error") || output.toLowerCase().includes("failed"), + `expected invalid model failure output\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + return; + } + + assert(agentId, `expected run output to include an agent id\nstdout:\n${result.stdout}`); + + const inspect = await ctx.paseo(["inspect", agentId], { timeout: 15_000 }); + assert.strictEqual(inspect.exitCode, 0, `inspect failed\nstdout:\n${inspect.stdout}\nstderr:\n${inspect.stderr}`); + + const runReportedCompleted = result.stdout.includes("completed"); + const inspectStillRunning = inspect.stdout.includes("Status running"); + + assert( + !(runReportedCompleted && inspectStillRunning), + `run reported completed while inspect still showed running\nrun stdout:\n${result.stdout}\ninspect stdout:\n${inspect.stdout}`, + ); +} + +async function main(): Promise { + try { + await setup(); + await test_invalid_opencode_model_does_not_report_completed_while_still_running(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } finally { + await cleanup(); + } +} + +main(); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index a29cd8ae3..af9942456 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,9 +1,9 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain } from "electron"; import log from "electron-log/main"; -import { resolvePaseoHome } from "@getpaseo/server"; +import { resolvePaseoHome, spawnProcess } from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -266,15 +266,11 @@ async function startDaemon(): Promise { args: invocation.args, }); - const child: ChildProcess = spawn( - invocation.command, - invocation.args, - { - detached: true, - env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" }, - stdio: ["ignore", "ignore", "ignore"], - }, - ); + const child: ChildProcess = spawnProcess(invocation.command, invocation.args, { + detached: true, + env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" }, + stdio: ["ignore", "ignore", "ignore"], + }); logDesktopDaemonLifecycle("detached spawn returned", { childPid: child.pid ?? null, diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index 88615f942..4245ce1b9 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; -import { spawn, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; +import { spawnProcess } from "@getpaseo/server"; import { createRequire } from "node:module"; import path from "node:path"; import { app } from "electron"; @@ -234,6 +235,7 @@ export function runCliPassthroughCommand(args: string[]): number { const result = spawnSync(invocation.command, invocation.args, { env: invocation.env, stdio: "inherit", + windowsHide: true, }); if (result.error) { throw result.error; @@ -252,7 +254,7 @@ function spawnAsync( options: { env: NodeJS.ProcessEnv }, ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { return new Promise((resolve, reject) => { - const child = spawn(command, args, { + const child = spawnProcess(command, args, { env: options.env, stdio: ["ignore", "pipe", "pipe"], }); @@ -260,10 +262,10 @@ function spawnAsync( let stdout = ""; let stderr = ""; - child.stdout.on("data", (data: Buffer) => { + child.stdout!.on("data", (data: Buffer) => { stdout += data.toString(); }); - child.stderr.on("data", (data: Buffer) => { + child.stderr!.on("data", (data: Buffer) => { stderr += data.toString(); }); diff --git a/packages/desktop/src/integrations/integrations-manager.ts b/packages/desktop/src/integrations/integrations-manager.ts index b70894b71..17746a70a 100644 --- a/packages/desktop/src/integrations/integrations-manager.ts +++ b/packages/desktop/src/integrations/integrations-manager.ts @@ -200,7 +200,20 @@ export async function installCli(): Promise { if (await pathOrSymlinkExists(targetPath)) { await fs.unlink(targetPath); } - await fs.copyFile(installSourcePath, targetPath); + // Generate a thin .cmd trampoline that delegates to the bundled shim. + // Only the app install path is baked in — internal details (asar layout, + // entrypoint scripts) live in the bundled shim and update with the app. + const cmdContent = [ + "@echo off", + `set "BUNDLED_CLI=${shimPath}"`, + `if not exist "%BUNDLED_CLI%" (`, + ` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`, + ` exit /b 1`, + `)`, + `call "%BUNDLED_CLI%" %*`, + `exit /b %errorlevel%`, + ].join("\r\n"); + await fs.writeFile(targetPath, cmdContent, "utf-8"); } else { if (await pathOrSymlinkExists(targetPath)) { await fs.unlink(targetPath); From d98d5457edc06297559cffe6836233a197c63865 Mon Sep 17 00:00:00 2001 From: Benjamin Kitt Date: Wed, 8 Apr 2026 21:10:11 -0500 Subject: [PATCH 14/32] Fix commands not loading for Pi agent (#225) --- .../server/agent/providers/acp-agent.test.ts | 46 +++++++++++- .../src/server/agent/providers/acp-agent.ts | 73 +++++++++++++++++++ .../server/agent/providers/pi-acp-agent.ts | 2 + 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index b686497b5..a5bad22f5 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -415,10 +415,39 @@ describe("transformPiModels", () => { }); describe("ACPAgentSession slash commands", () => { - test("caches ACP available commands for listCommands", async () => { + test("returns immediately for ACP sessions that do not wait for async command discovery", async () => { const session = createSession(); - expect(await session.listCommands()).toEqual([]); + await expect(session.listCommands()).resolves.toEqual([]); + }); + + test("waits for async available_commands_update when enabled", async () => { + const session = new ACPAgentSession( + { + provider: "pi", + cwd: "/tmp/paseo-acp-test", + }, + { + provider: "pi", + logger: createTestLogger(), + defaultCommand: ["pi-acp"], + defaultModes: [], + modelTransformer: transformPiModels, + sessionResponseTransformer: transformPiSessionResponse, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + waitForInitialCommands: true, + initialCommandsWaitTimeoutMs: 1500, + }, + ); + + const listCommandsPromise = session.listCommands(); (session as any).translateSessionUpdate({ sessionUpdate: "available_commands_update", @@ -434,6 +463,19 @@ describe("ACPAgentSession slash commands", () => { ], }); + expect(await listCommandsPromise).toEqual([ + { + name: "research_codebase", + description: "Search the workspace for relevant files", + argumentHint: "", + }, + { + name: "create_plan", + description: "Draft a plan for the requested work", + argumentHint: "", + }, + ]); + expect(await session.listCommands()).toEqual([ { name: "research_codebase", diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index f0a90b0ac..83802b2b3 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -125,6 +125,8 @@ type ACPAgentClientOptions = { thinkingOptionId: string, ) => Promise; capabilities?: AgentCapabilityFlags; + waitForInitialCommands?: boolean; + initialCommandsWaitTimeoutMs?: number; }; type ACPAgentSessionOptions = { @@ -144,6 +146,8 @@ type ACPAgentSessionOptions = { capabilities: AgentCapabilityFlags; handle?: AgentPersistenceHandle; launchEnv?: Record; + waitForInitialCommands?: boolean; + initialCommandsWaitTimeoutMs?: number; }; type SpawnedACPProcess = { @@ -302,6 +306,8 @@ export class ACPAgentClient implements AgentClient { sessionId: string, thinkingOptionId: string, ) => Promise; + private readonly waitForInitialCommands: boolean; + private readonly initialCommandsWaitTimeoutMs: number; constructor(options: ACPAgentClientOptions) { this.provider = options.provider; @@ -314,6 +320,8 @@ export class ACPAgentClient implements AgentClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.thinkingOptionWriter = options.thinkingOptionWriter; + this.waitForInitialCommands = options.waitForInitialCommands ?? false; + this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; } async createSession( @@ -335,6 +343,8 @@ export class ACPAgentClient implements AgentClient { thinkingOptionWriter: this.thinkingOptionWriter, capabilities: this.capabilities, launchEnv: launchContext?.env, + waitForInitialCommands: this.waitForInitialCommands, + initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, }, ); await session.initializeNewSession(); @@ -375,6 +385,8 @@ export class ACPAgentClient implements AgentClient { capabilities: this.capabilities, handle, launchEnv: launchContext?.env, + waitForInitialCommands: this.waitForInitialCommands, + initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, }); await session.initializeResumedSession(); return session; @@ -617,6 +629,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { private lastActivityAt: string | null = null; private configOptions: SessionConfigOption[] = []; private cachedCommands: AgentSlashCommand[] = []; + private commandsReadyDeferred: { promise: Promise; resolve: () => void } | null = null; + private commandsReadySettled = false; + private waitForInitialCommands: boolean; + private initialCommandsWaitTimeoutMs: number; private currentTurnUsage: AgentUsage | undefined; private activeForegroundTurnId: string | null = null; private closed = false; @@ -645,6 +661,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.currentModel = config.model ?? null; this.thinkingOptionId = config.thinkingOptionId ?? null; this.currentTitle = config.title ?? null; + this.waitForInitialCommands = options.waitForInitialCommands ?? false; + this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; } get id(): string | null { @@ -876,7 +894,59 @@ export class ACPAgentSession implements AgentSession, ACPClient { return this.currentMode; } + private ensureCommandsReadyDeferred(): void { + if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) { + return; + } + + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + this.commandsReadyDeferred = { promise, resolve }; + } + + private settleCommandsReady(): void { + if (this.commandsReadySettled) { + return; + } + this.commandsReadySettled = true; + this.commandsReadyDeferred?.resolve(); + this.commandsReadyDeferred = null; + } + + private async waitForCommandsReady(): Promise { + const deferred = this.commandsReadyDeferred; + if (!deferred) { + return; + } + + let timer: ReturnType | null = null; + try { + await Promise.race([ + deferred.promise, + new Promise((resolve) => { + timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + async listCommands(): Promise { + if (this.cachedCommands.length > 0) { + return this.cachedCommands; + } + if (!this.waitForInitialCommands || this.closed) { + return this.cachedCommands; + } + + this.ensureCommandsReadyDeferred(); + await this.waitForCommandsReady(); + this.settleCommandsReady(); return this.cachedCommands; } @@ -1046,6 +1116,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { } this.closed = true; + this.settleCommandsReady(); + for (const pending of this.pendingPermissions.values()) { pending.resolve({ outcome: { outcome: "cancelled" } }); } @@ -1392,6 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { description: command.description, argumentHint: "", })); + this.settleCommandsReady(); return []; default: return []; diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts index 94d24f4d2..7d3d9588d 100644 --- a/packages/server/src/server/agent/providers/pi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts @@ -298,6 +298,8 @@ export class PiACPAgentClient extends ACPAgentClient { await connection.setSessionMode({ sessionId, modeId: thinkingOptionId }); }, capabilities: PI_CAPABILITIES, + waitForInitialCommands: true, + initialCommandsWaitTimeoutMs: 1500, }); } From 602b54874599c918e63af5500cb14e349f55dfa7 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 9 Apr 2026 04:26:53 +0200 Subject: [PATCH 15/32] feat(app): Add WebStorm editor target (#220) * Add WebStorm editor target * Fix rebase type errors and add TODO date tag - Await listAvailableEditorTargets() which became async on main - Wrap sendAgentMessage callback to match Promise return type - Add TODO(2026-07) date to legacy editor target ids comment --------- Co-authored-by: Mohamed Boudra --- .../assets/images/editor-apps/webstorm.png | Bin 0 -> 4677 bytes .../src/components/icons/editor-app-icons.tsx | 21 +++++++- .../src/hooks/use-preferred-editor.test.ts | 6 +++ .../server/src/server/editor-targets.test.ts | 18 ++++++- packages/server/src/server/editor-targets.ts | 9 +++- packages/server/src/server/session.ts | 30 ++++++++++-- .../src/server/session.workspaces.test.ts | 46 +++++++++++++++--- packages/server/src/shared/literal-union.ts | 3 ++ packages/server/src/shared/messages.ts | 27 ++++++++-- .../src/shared/messages.workspaces.test.ts | 45 +++++++++++++++++ 10 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 packages/app/assets/images/editor-apps/webstorm.png create mode 100644 packages/server/src/shared/literal-union.ts diff --git a/packages/app/assets/images/editor-apps/webstorm.png b/packages/app/assets/images/editor-apps/webstorm.png new file mode 100644 index 0000000000000000000000000000000000000000..7837d566e241fca334ce4d7fce391a7550e348ae GIT binary patch literal 4677 zcmZ`-by$?!xBdnOgpm@5kdUE;fuXww7*aq&5C-WSYDmdJIz>99I}8K_R0IiWkdzMT z5D*EGQh#uc_nhBzpL_T7tZ%LTuJ>JQuf6~KqP4YDNFj6(002nUR8hJY9(id*gcskZ z7`OQg2ey;flm~$7_^am@1Q&alm8z~L0QhkLKu{O}oL)dd8vx+N4**}x0YEYX0BBsZ z8+4>E5a_U)qDN6cMAPeV-& zNh_=~!onJBX@l@_cD+;q(ms+Gp|g#r1ol8RU-th=78g_Vu0fb?I)|AzkU@;6%> z<74Awh{8DAxOiNgNLGMfQ23APf2aOWsnP#0{jby?sWji^!2fjtf8Xw3=*9KQLZtcr zc`8{*>yA_f0Faxiq2%xSfOd?1UG>Lq{GK}--@eMsOhJ?db7Zg~jwN!4fsGmT8FpE0 zGo?XlwRO-uqoTFbiJMTGFa@p=w7R4ItoHN5g{tdEN)OnGlNc#$-tWu@e)ip+pZzi4 z#7Dd!I2zzOKh=DIYd*-?&Gr9EJfxOGCNi>a)p} zuR*-wK2rr5t|?BRvQ;6f3$@!th6c=Bt$Q|Bne@G?j~umM<9=vyNxestwh-r0CNR`! z>O=Jl9|nFwmzDRH?9+MgMP2K#ncYHn&kt)69}L)uaN7Jtre%L-l|N8@Ml#73ZT(pD z`BMQ`f2-Wlh#Ef;;$4$KXmc-)%(R*H?v3_DDQ1iV5mI`<1)f9^5(+-J+FdS5v( z(1t+7MQpEXr%-`%*Zb-g)4iO%<}j151ZnI@`R-z3+g9~^Dbq@~=)lYk+5Px?k8PbA z*3W3iyqKgx_n;mjjDo>yt`xtZVz0~DHRBAE=AH$xi-^AJu)nFp&Ou;O_M5ZXwxyQp zDcmJO8;w!4V2yXwHpN%c-=Q6I<`boo1|qjZ3u~eclS4^5T++3TpZ!{n^TvC%-e2KR z<-+*9x^8Tte#TaEAU@+AI*xM{_X)jIzF#oqyxylH<+ zuLs=W+V@pfc@hhn*x>g2oR+PvpNoc>$c-HQF|QI&e{$=NemiXN+LWknk0TD^^gdK{ zoo*OiitS)+)S{6sW{b~vSOx3DAvaU>B@%KJc}<80we&=^ zAHcdZ4+>h2+9vH!%o#F$5q2Y6FM8vgGxB0MI-*b5t@dW*XH=KE3Y?t_zz1|g!~77Dy>Q=rsmdyVF@yC$gTaKvIC;TUrK(qocep(c7j8{=~Ki- zosm2eYs`IIk!)hWhl=l}MgqlTH*anznrMKkgkfVj<2f2xYh0PtqVL&CjrwpxZami} zZ%F1OLGzG-{UI8l{JJ)00>Vl+|@wkf=dRN|>uLkUUS8oah#&yU@*CA}aj@i#j% zMI47-W6he(92FF-V3j8Rq2scPTw6v4iZ7oo?u0KjToL=kwc>Uc?a8YAh+;2^{Whv! z=xiaBJjUH6H$6R)*SJ0^_w~FVyz?<*Y+4#Ml!m6rtU2)bcsYK!NQGR-OiwRV7IzxO z@3w@}$P^}KXJ;=&9J4NlFB#qX_L~v0>5#PxiFoW+V?|Aoc~YerI{-O89KEAd`SACz z?*4x0W~IqH%Y~L;zh`dCotqfK*v*C3&|7dgzA*&;aIuYGHt=lc&Bu=6~4*}i2EP$4CKl2`Nc6v=(byRKDxGd z`h81BU!OqqEF}fHI4c}JMb3{|ld%oZc1zP`jptw~5mQR(tZm?(_wM7) zPGtshidjvBalJ-{zp^r+!3%Wwhe-0}p#nw2CcipEvXI1{c+`W&T?g4;o^JIgx>3Q= zTtM+F$b%4pR8BP(o*u5D?Q1F(`fGA8;uou zWlKMQd{(isVZV<^|Hc$AbCmIo@1hUQPFtYx4QJEF<5h<3^1;a4M`0q-sR9H+VhW2` z*Y*%&hfp`C>FblP?8q6_!3oL9_@b-Z+fED;?qE_<(#agW7^sJevW198%veM|%)h5(7Qb^w*#$)gsU ze&>zt&lHIjy`5AkZVX7N8RUX2MtC?72r}=_I59@d6akSFXj^EKdEeH)HH2X4TJKI~ zRu(Hj)XO^Dzsq{n5G_g{E-^UVrx#F_(_!sa;ziX|QP1t9L7!r0&Js0o7$_NOIda}I zkRzp(%gA;m_hXhzgPQbjk-SAu$?69?Xs5|4P%Jes50I}5FQ&84+ zuv!bH+wWnjZGvimV7bNYI&2<0Lm~bcuR@FC__eXODIg1#&UP(b51(u~kF`fWYaW@Y z%ZyB;vu{22TCXsfZ;q5KbS-=!m*eR*^IfHq+}w+;{q@0}c4hH;u`N63PAWnfCzT5+ z1)?O@=VtSt5o)p;0pJ zuLfmfso)0on#NYnEF?%(Yl1WS%?fRaEUj-=*aXXCfKJg)D*?q_xoWI5tk*d1JJY-M zWI|9%Db7bO-`Sp)fmK9Agt)mn;KaQ;z(=v;=ajE<{n!~s5aLVHmkfQF2)vQo60+{j z+xWsavf{8y&;Yi_*p>96Do# z^xuH?vY2+)Pg;st+jmaxFQ8TPh)9FT1gpWYr1~sx-t56xRbyn~5T$>P)8ZK<;Sgukk=_6v;7~V*o{Wi1w zT{>ONLke;p-l=GQ-~LN@j{eDUVRbsKW&n0kXX;3NJfu4dfLi&(!)#VhIKpm7%po;9 zzNT==lzzfHl*{Dd4MI<(rxD8bZ$zALxZG6;c_v&rcs+lr6iF6pC3mHR?}%J>TG4u1 zT|nX$#E$NCaUs>%b?UZR$gKzS1EM&%@g)mx@hy#MgzMjMd=x; zsZI5kF?i~c_e7&ed)xUFa;8AzkzoAy4Iv@mdoSk)qIqSdK5`SB=5phe;G;9GtQc4E zJEcpe?%Wc-&)sBW`{r52OH$Hc6nBx4VCLKR!Ys(@l#%Bl+Fs(!Ig@8y+3W}NxGa*f z=Tzx?t_CPdjUOK~bl#sHZnkuWfhBM93xsV9RMV+}AH^M!n;zbgjbyIiBuYR_zR16N zum4<`i|Gy%D&~%}=U~?ROxa;H_h3y|EwJE``k*eiaPFg0Q77kjtwB%Hg(pYcq5{in z)aF;w%3%amV;egakMp2{Ig6Z<^qU*gX{P>Z zTnqVTgD3BGoovHsxq2;+<&C7$#45T>#Vd2f<{k9(+{GVe%-m%%e`^e8#>AFQXW(o{ zBM9tS1!5F4=YRd^!LhG587GZqk0pKBWTBvhPA#TovjydV%kn2Bc0wFR_ge+SMWOf0 zCg&DBgGN{;H*_3guVd)v7*cP6%SjLL2Fs1Ablz$dj+&{pY*i?ld>7b64NK1Ko5>}= z2^f}Y3hpaz>{vrisIg6lzM2n3aCJjf1&8pdO*pjrHmAH%6Q(^TdV(ckZ`hi9X=a>j zQO6&L)hLHbqiiSPqG}8txxGE-O@-1aWq$QUF1saSTJz$2rQC!k!-k(>`sw-x0>kLz7b=Z@(A( z+b<|TWF*`vShi~){W?Hg#e+C)mLiz6A1f?}zg1-&NkzIf_hb{5e?3?WtUz>5o}!sG U = { +const EDITOR_APP_IMAGES: Record = { cursor: require("../../../assets/images/editor-apps/cursor.png"), vscode: require("../../../assets/images/editor-apps/vscode.png"), + webstorm: require("../../../assets/images/editor-apps/webstorm.png"), zed: require("../../../assets/images/editor-apps/zed.png"), finder: require("../../../assets/images/editor-apps/finder.png"), explorer: require("../../../assets/images/editor-apps/file-explorer.png"), @@ -18,10 +24,21 @@ const EDITOR_APP_IMAGES: Record = { }; /* eslint-enable @typescript-eslint/no-require-imports */ +export function hasBundledEditorAppIcon( + editorId: EditorTargetId, +): editorId is KnownEditorTargetId { + return isKnownEditorTargetId(editorId); +} + export function EditorAppIcon({ editorId, size = 16, + color, }: EditorAppIconProps) { + if (!hasBundledEditorAppIcon(editorId)) { + return ; + } + return ( { expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer"); }); + it("keeps unknown editor ids when they are still available", () => { + expect(resolvePreferredEditorId(["unknown-editor", "cursor"], "unknown-editor")).toBe( + "unknown-editor", + ); + }); + it("returns null when no editors are available", () => { expect(resolvePreferredEditorId([], "cursor")).toBeNull(); }); diff --git a/packages/server/src/server/editor-targets.test.ts b/packages/server/src/server/editor-targets.test.ts index 0b0f68a31..c63191783 100644 --- a/packages/server/src/server/editor-targets.test.ts +++ b/packages/server/src/server/editor-targets.test.ts @@ -3,7 +3,7 @@ import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets describe("editor-targets", () => { it("lists available editors in deterministic order", async () => { - const available = new Set(["code", "cursor", "explorer"]); + const available = new Set(["code", "cursor", "explorer", "webstorm"]); const editors = await listAvailableEditorTargets({ platform: "win32", @@ -13,6 +13,7 @@ describe("editor-targets", () => { expect(editors).toEqual([ { id: "cursor", label: "Cursor" }, { id: "vscode", label: "VS Code" }, + { id: "webstorm", label: "WebStorm" }, { id: "explorer", label: "Explorer" }, ]); }); @@ -97,4 +98,19 @@ describe("editor-targets", () => { ), ).rejects.toThrow("Editor target unavailable: Finder"); }); + + it("rejects unknown editor ids", async () => { + await expect( + openInEditorTarget( + { + editorId: "unknown-editor", + path: "/tmp/repo", + }, + { + existsSync: () => true, + findExecutable: () => null, + }, + ), + ).rejects.toThrow("Unknown editor target: unknown-editor"); + }); }); diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts index f5e388c0b..b2549dd7f 100644 --- a/packages/server/src/server/editor-targets.ts +++ b/packages/server/src/server/editor-targets.ts @@ -1,7 +1,11 @@ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import { posix, win32 } from "node:path"; -import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js"; +import type { + EditorTargetDescriptorPayload, + EditorTargetId, + KnownEditorTargetId, +} from "../shared/messages.js"; import { findExecutable, quoteWindowsArgument, @@ -9,7 +13,7 @@ import { } from "../utils/executable.js"; type EditorTargetDefinition = { - id: EditorTargetId; + id: KnownEditorTargetId; label: string; command: string; platforms?: readonly NodeJS.Platform[]; @@ -29,6 +33,7 @@ type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & { const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [ { id: "cursor", label: "Cursor", command: "cursor" }, { id: "vscode", label: "VS Code", command: "code" }, + { id: "webstorm", label: "WebStorm", command: "webstorm" }, { id: "zed", label: "Zed", command: "zed" }, { id: "finder", label: "Finder", command: "open", platforms: ["darwin"] }, { id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] }, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 80bf24cbb..2c5c6c3f5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -8,6 +8,7 @@ import { homedir } from "node:os"; import { z } from "zod"; import type { ToolSet } from "ai"; import { + isLegacyEditorTargetId, serializeAgentStreamEvent, type AgentSnapshotPayload, type SessionInboundMessage, @@ -28,6 +29,7 @@ import { type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, + type EditorTargetDescriptorPayload, type EditorTargetId, type ProjectPlacementPayload, type WorkspaceDescriptorPayload, @@ -202,13 +204,14 @@ const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; // the entire session message if they encounter an unknown provider. const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; +const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50"; -function clientSupportsAllProviders(appVersion: string | null): boolean { +function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean { if (!appVersion) return false; // Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45" const base = appVersion.replace(/-.*$/, ""); const parts = base.split(".").map(Number); - const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number); + const minParts = minVersion.split(".").map(Number); for (let i = 0; i < minParts.length; i++) { const a = parts[i] ?? 0; const b = minParts[i] ?? 0; @@ -218,6 +221,14 @@ function clientSupportsAllProviders(appVersion: string | null): boolean { return true; } +function clientSupportsAllProviders(appVersion: string | null): boolean { + return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS); +} + +function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { + return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS); +} + const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; @@ -1215,6 +1226,15 @@ export class Session { return LEGACY_PROVIDER_IDS.has(provider); } + private filterEditorsForClient( + editors: EditorTargetDescriptorPayload[], + ): EditorTargetDescriptorPayload[] { + if (clientSupportsFlexibleEditorIds(this.appVersion)) { + return editors; + } + return editors.filter((editor) => isLegacyEditorTargetId(editor.id)); + } + private matchesAgentFilter(options: { agent: AgentSnapshotPayload; project: ProjectPlacementPayload; @@ -6095,7 +6115,7 @@ export class Session { } async getAvailableEditorTargets() { - return listAvailableEditorTargets(); + return this.filterEditorsForClient(await listAvailableEditorTargets()); } async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise { @@ -7523,7 +7543,9 @@ export class Session { listStoredAgents: () => this.agentStorage.list(), listLiveAgents: () => this.agentManager.listAgents(), resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier), - sendAgentMessage: (agentId, text) => this.handleSendAgentMessage(agentId, text), + sendAgentMessage: async (agentId, text) => { + await this.handleSendAgentMessage(agentId, text); + }, }); } catch (error) { this.emitChatRpcError(request, error); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index c054ca006..eb6d331fc 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -61,7 +61,7 @@ function makeAgent(input: { }; } -function createSessionForWorkspaceTests(): Session { +function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): Session { const logger = { child: () => logger, trace: vi.fn(), @@ -73,6 +73,7 @@ function createSessionForWorkspaceTests(): Session { const session = new Session({ clientId: "test-client", + appVersion: options.appVersion ?? null, onMessage: vi.fn(), logger: logger as any, downloadTokenStore: {} as any, @@ -1246,19 +1247,52 @@ describe("workspace aggregation", () => { test("list_available_editors_request returns available targets", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; + const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" }) as any; session.emit = (message: any) => emitted.push(message); - session.getAvailableEditorTargets = async () => [ - { id: "cursor", label: "Cursor" }, - { id: "finder", label: "Finder" }, - ]; + session.getAvailableEditorTargets = async () => + session.filterEditorsForClient([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "finder", label: "Finder" }, + { id: "unknown-editor", label: "Unknown Editor" }, + ]); await session.handleMessage({ type: "list_available_editors_request", requestId: "req-editors", }); + const response = emitted.find( + (message) => message.type === "list_available_editors_response", + ) as any; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.editors).toEqual([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "finder", label: "Finder" }, + { id: "unknown-editor", label: "Unknown Editor" }, + ]); + }); + + test("list_available_editors_request filters unsupported ids for legacy clients", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" }) as any; + + session.emit = (message: any) => emitted.push(message); + session.getAvailableEditorTargets = async () => + session.filterEditorsForClient([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "unknown-editor", label: "Unknown Editor" }, + { id: "finder", label: "Finder" }, + ]); + + await session.handleMessage({ + type: "list_available_editors_request", + requestId: "req-editors-legacy", + }); + const response = emitted.find( (message) => message.type === "list_available_editors_response", ) as any; diff --git a/packages/server/src/shared/literal-union.ts b/packages/server/src/shared/literal-union.ts new file mode 100644 index 000000000..ad05ccd9b --- /dev/null +++ b/packages/server/src/shared/literal-union.ts @@ -0,0 +1,3 @@ +// Adapted from type-fest's LiteralUnion: +// https://github.com/sindresorhus/type-fest/blob/main/source/literal-union.d.ts +export type LiteralUnion = T | (U & Record); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 4805343b4..1d89d5bea 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -47,6 +47,7 @@ import { LoopLogsResponseSchema, LoopStopResponseSchema, } from "../server/loop/rpc-schemas.js"; +import type { LiteralUnion } from "./literal-union.js"; import type { AgentCapabilityFlags, AgentModelDefinition, @@ -1096,14 +1097,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({ requestId: z.string(), }); -export const EditorTargetIdSchema = z.enum([ +// TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids. +export const LEGACY_EDITOR_TARGET_IDS = [ "cursor", "vscode", "zed", "finder", "explorer", "file-manager", -]); +] as const; + +export const KNOWN_EDITOR_TARGET_IDS = [...LEGACY_EDITOR_TARGET_IDS, "webstorm"] as const; + +export const KnownEditorTargetIdSchema = z.enum(KNOWN_EDITOR_TARGET_IDS); +export const LegacyEditorTargetIdSchema = z.enum(LEGACY_EDITOR_TARGET_IDS); +export const EditorTargetIdSchema = z.string().trim().min(1); + +const KNOWN_EDITOR_TARGET_ID_SET = new Set(KNOWN_EDITOR_TARGET_IDS); +const LEGACY_EDITOR_TARGET_ID_SET = new Set(LEGACY_EDITOR_TARGET_IDS); + +export function isKnownEditorTargetId(value: string): value is KnownEditorTargetId { + return KNOWN_EDITOR_TARGET_ID_SET.has(value); +} + +export function isLegacyEditorTargetId(value: string): value is LegacyEditorTargetId { + return LEGACY_EDITOR_TARGET_ID_SET.has(value); +} export const EditorTargetDescriptorPayloadSchema = z.object({ id: EditorTargetIdSchema, @@ -2626,7 +2645,9 @@ export type ProjectCheckoutLitePayload = z.infer; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; -export type EditorTargetId = z.infer; +export type KnownEditorTargetId = z.infer; +export type LegacyEditorTargetId = z.infer; +export type EditorTargetId = LiteralUnion; export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 2a8796577..bf785602b 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -38,6 +38,24 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("list_available_editors_request"); }); + test("parses open_in_editor_request with flexible editor ids", () => { + const knownEditor = SessionInboundMessageSchema.parse({ + type: "open_in_editor_request", + requestId: "req-open-webstorm", + editorId: "webstorm", + path: "/tmp/repo", + }); + const unknownEditor = SessionInboundMessageSchema.parse({ + type: "open_in_editor_request", + requestId: "req-open-custom", + editorId: "unknown-editor", + path: "/tmp/repo", + }); + + expect(knownEditor.type).toBe("open_in_editor_request"); + expect(unknownEditor.type).toBe("open_in_editor_request"); + }); + test("parses open_in_editor_response", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "open_in_editor_response", @@ -50,6 +68,33 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("open_in_editor_response"); }); + test("parses list_available_editors_response with unknown editor ids", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "list_available_editors_response", + payload: { + requestId: "req-editors", + editors: [ + { id: "cursor", label: "Cursor" }, + { id: "unknown-editor", label: "Unknown Editor" }, + ], + error: null, + }, + }); + + expect(parsed.type).toBe("list_available_editors_response"); + }); + + test("rejects empty editor ids", () => { + const result = SessionInboundMessageSchema.safeParse({ + type: "open_in_editor_request", + requestId: "req-open-empty", + editorId: "", + path: "/tmp/repo", + }); + + expect(result.success).toBe(false); + }); + test("rejects invalid workspace update payload", () => { const result = SessionOutboundMessageSchema.safeParse({ type: "workspace_update", From 83f540f10aaf23e17716947595aa574e90cca21f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 09:45:51 +0700 Subject: [PATCH 16/32] fix(app): wrap daemon settings section in React Query with loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents the daemon section from flashing "not running" / "PID —" while IPC data loads. The whole card now shows a spinner until the first fetch completes, and mutations (restart/stop/start) optimistically update the query cache with the returned status. --- .../components/desktop-updates-section.tsx | 291 +++++++++--------- .../src/desktop/hooks/use-daemon-status.ts | 53 ++++ 2 files changed, 195 insertions(+), 149 deletions(-) create mode 100644 packages/app/src/desktop/hooks/use-daemon-status.ts diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index ef8be49ab..f196c7b19 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; -import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; import { @@ -21,15 +20,12 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, - getDesktopDaemonLogs, - getDesktopDaemonStatus, restartDesktopDaemon, shouldUseDesktopDaemon, startDesktopDaemon, stopDesktopDaemon, - type DesktopDaemonLogs, - type DesktopDaemonStatus, } from "@/desktop/daemon/desktop-daemon"; +import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; export interface LocalDaemonSectionProps { appVersion: string | null; @@ -40,44 +36,18 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); const { settings, updateSettings } = useAppSettings(); - const [daemonStatus, setDaemonStatus] = useState(null); - const [daemonVersion, setDaemonVersion] = useState(null); - const [statusError, setStatusError] = useState(null); + const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus(); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); const [statusMessage, setStatusMessage] = useState(null); - const [daemonLogs, setDaemonLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); - const loadDaemonData = useCallback(() => { - if (!showSection) { - return Promise.resolve(); - } - return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]) - .then(([status, logs]) => { - setDaemonStatus(status); - setDaemonLogs(logs); - setDaemonVersion(status.version); - setStatusError(null); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setStatusError(message); - }); - }, [showSection]); - - useFocusEffect( - useCallback(() => { - if (!showSection) { - return undefined; - } - void loadDaemonData(); - return undefined; - }, [loadDaemonData, showSection]), - ); + const daemonStatus = data?.status ?? null; + const daemonLogs = data?.logs ?? null; + const daemonVersion = daemonStatus?.version ?? null; const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonStatusStateText = @@ -116,12 +86,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon; void action() - .then((status) => { - setDaemonStatus(status); + .then((newStatus) => { + setStatus(newStatus); setStatusMessage( daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.", ); - return loadDaemonData(); + refetch(); }) .catch((error) => { console.error("[Settings] Failed to change desktop daemon state", error); @@ -136,7 +106,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD console.error("[Settings] Failed to open desktop daemon action confirmation", error); Alert.alert("Error", "Unable to open the daemon confirmation dialog."); }); - }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]); + }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]); const handleToggleDaemonManagement = useCallback(() => { if (isUpdatingDaemonManagement) { @@ -182,9 +152,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD : Promise.resolve(daemonStatus ?? null); void stopPromise - .then(() => updateSettings({ manageBuiltInDaemon: false })) - .then(() => loadDaemonData()) + .then((newStatus) => { + if (newStatus) { + setStatus(newStatus); + } + return updateSettings({ manageBuiltInDaemon: false }); + }) .then(() => { + refetch(); setStatusMessage("Built-in daemon paused and stopped."); }) .catch((error) => { @@ -202,7 +177,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD }, [ daemonStatus, isUpdatingDaemonManagement, - loadDaemonData, + refetch, + setStatus, settings.manageBuiltInDaemon, updateSettings, ]); @@ -277,126 +253,138 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD Advanced settings - - - - Status - Only the built-in desktop daemon is shown here. - - - {daemonStatusStateText} - {daemonStatusDetailText} - + {isLoading ? ( + + - {showLifecycleControls ? ( - <> + ) : ( + <> + + + + Status + + Only the built-in desktop daemon is shown here. + + + + {daemonStatusStateText} + {daemonStatusDetailText} + + + {showLifecycleControls ? ( + <> + + + Daemon management + + {isDaemonManagementPaused + ? "Paused. The built-in daemon stays stopped until you start it again." + : "Enabled. Paseo can manage the built-in daemon from the desktop app."} + + + + + + + {daemonActionLabel} + {daemonActionMessage} + {statusMessage ? {statusMessage} : null} + + + + + ) : null} - Daemon management + Log file - {isDaemonManagementPaused - ? "Paused. The built-in daemon stays stopped until you start it again." - : "Enabled. Paseo can manage the built-in daemon from the desktop app."} + {daemonLogs?.logPath ?? "Log path unavailable."} + + + + {daemonLogs?.logPath ? ( + + ) : null} + + + + + + Full status + + Runs `paseo daemon status` and shows the output. - - - {daemonActionLabel} - {daemonActionMessage} - {statusMessage ? {statusMessage} : null} - - - - - ) : null} - - - Log file - {daemonLogs?.logPath ?? "Log path unavailable."} - - {daemonLogs?.logPath ? ( - - ) : null} - - - - - - Full status - - Runs `paseo daemon status` and shows the output. - - - - - - {daemonVersionMismatch ? ( - - - App and daemon versions don't match. Update both to the same version for the best - experience. - - - ) : null} + {daemonVersionMismatch ? ( + + + App and daemon versions don't match. Update both to the same version for the best + experience. + + + ) : null} + + )} ({ flexWrap: "wrap", justifyContent: "flex-end", }, + loadingCard: { + alignItems: "center", + justifyContent: "center", + paddingVertical: theme.spacing[6], + }, statusValueGroup: { alignItems: "flex-end", gap: 2, diff --git a/packages/app/src/desktop/hooks/use-daemon-status.ts b/packages/app/src/desktop/hooks/use-daemon-status.ts new file mode 100644 index 000000000..5260ae43b --- /dev/null +++ b/packages/app/src/desktop/hooks/use-daemon-status.ts @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getDesktopDaemonLogs, + getDesktopDaemonStatus, + shouldUseDesktopDaemon, + type DesktopDaemonLogs, + type DesktopDaemonStatus, +} from "@/desktop/daemon/desktop-daemon"; + +const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const; + +interface DaemonStatusData { + status: DesktopDaemonStatus; + logs: DesktopDaemonLogs; +} + +export function useDaemonStatus() { + const queryClient = useQueryClient(); + const enabled = shouldUseDesktopDaemon(); + + const query = useQuery({ + queryKey: DAEMON_STATUS_QUERY_KEY, + enabled, + staleTime: 30_000, + refetchOnMount: "always", + queryFn: async () => { + const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]); + return { status, logs }; + }, + }); + + const setStatus = useCallback( + (status: DesktopDaemonStatus) => { + queryClient.setQueryData(DAEMON_STATUS_QUERY_KEY, (prev) => + prev ? { ...prev, status } : undefined, + ); + }, + [queryClient], + ); + + const refetch = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: DAEMON_STATUS_QUERY_KEY }); + }, [queryClient]); + + return { + data: query.data ?? null, + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : null, + setStatus, + refetch, + }; +} From f83a4d08dacf67b898acc1a1b6a87c10e843bb24 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 09:56:04 +0700 Subject: [PATCH 17/32] Add configurable send behavior --- .../app/src/components/agent-input-area.tsx | 4 ++ packages/app/src/components/message-input.tsx | 67 ++++++++++++++----- packages/app/src/hooks/use-settings.ts | 4 ++ packages/app/src/screens/settings-screen.tsx | 60 +++++++++++++---- 4 files changed, 105 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 847f23488..8f549b05c 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -48,6 +48,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { submitAgentInput } from "@/components/agent-input-submit"; +import { useAppSettings } from "@/hooks/use-settings"; type QueuedMessage = { id: string; @@ -131,6 +132,8 @@ export function AgentInputArea({ agentDirectoryStatus === "revalidating" || agentDirectoryStatus === "error_after_ready"); + const { settings: appSettings } = useAppSettings(); + const agentState = useSessionStore( useShallow((state) => { const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; @@ -742,6 +745,7 @@ export function AgentInputArea({ voiceServerId={serverId} voiceAgentId={agentId} isAgentRunning={isAgentRunning} + defaultSendBehavior={appSettings.sendBehavior} onQueue={handleQueue} onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onKeyPress={handleCommandKeyPress} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 8e18b3706..649f6e2e1 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -87,6 +87,9 @@ export interface MessageInputProps { voiceAgentId?: string; /** When true and there's sendable content, calls onQueue instead of onSubmit */ isAgentRunning?: boolean; + /** Controls what the default send action (Enter, send button, dictation) does + * when the agent is running. "interrupt" sends immediately, "queue" queues. */ + defaultSendBehavior?: "interrupt" | "queue"; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ @@ -208,6 +211,7 @@ export const MessageInput = forwardRef(funct voiceServerId, voiceAgentId, isAgentRunning = false, + defaultSendBehavior = "interrupt", onQueue, onSubmitLoadingPress, onKeyPress: onKeyPressCallback, @@ -352,11 +356,17 @@ export const MessageInput = forwardRef(funct if (shouldAutoSend) { const imageAttachments = images.length > 0 ? images : undefined; - onSubmit({ - text: nextValue, - images: imageAttachments, - forceSend: isAgentRunning || undefined, - }); + // Respect send behavior setting: when "queue", dictation queues too. + if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { + onQueue({ text: nextValue, images: imageAttachments }); + onChangeText(""); + } else { + onSubmit({ + text: nextValue, + images: imageAttachments, + forceSend: isAgentRunning || undefined, + }); + } } else { onChangeText(nextValue); } @@ -367,7 +377,7 @@ export const MessageInput = forwardRef(funct }); } }, - [onChangeText, onSubmit, images, isAgentRunning], + [onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior], ); const handleDictationError = useCallback( @@ -578,6 +588,26 @@ export const MessageInput = forwardRef(funct onHeightChange?.(MIN_INPUT_HEIGHT); }, [value, images, onQueue, onChangeText, onHeightChange]); + // Default send action: respects the sendBehavior setting. + // When "interrupt" (default), primary action sends immediately (interrupts). + // When "queue", primary action queues when agent is running. + const handleDefaultSendAction = useCallback(() => { + if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { + handleQueueMessage(); + } else { + handleSendMessage(); + } + }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); + + // Alternate send action: always the opposite of the default. + const handleAlternateSendAction = useCallback(() => { + if (defaultSendBehavior === "queue") { + handleSendMessage(); // interrupt + } else if (onQueue) { + handleQueueMessage(); // queue + } + }, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]); + // Web input height measurement function isTextAreaLike(v: unknown): v is TextAreaHandle { return typeof v === "object" && v !== null && "scrollHeight" in v; @@ -872,18 +902,18 @@ export const MessageInput = forwardRef(funct // Shift+Enter: add newline (default behavior, don't intercept) if (shiftKey) return; - // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running + // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { if (isSubmitDisabled || isSubmitLoading || disabled) return; event.preventDefault(); - handleQueueMessage(); + handleAlternateSendAction(); return; } - // Enter: send (interrupts agent if running) + // Enter: default send action (interrupt or queue, based on setting) if (isSubmitDisabled || isSubmitLoading || disabled) return; event.preventDefault(); - handleSendMessage(); + handleDefaultSendAction(); } const hasImages = images.length > 0; @@ -892,11 +922,14 @@ export const MessageInput = forwardRef(funct const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const isSendButtonDisabled = disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); + const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning; const submitAccessibilityLabel = canPressLoadingButton ? "Interrupt agent" - : isAgentRunning - ? "Send and interrupt" - : "Send message"; + : defaultActionQueues + ? "Queue message" + : isAgentRunning + ? "Send and interrupt" + : "Send message"; const handleInputChange = useCallback( (nextValue: string) => { @@ -1071,10 +1104,10 @@ export const MessageInput = forwardRef(funct {rightContent} - {hasSendableContent && isAgentRunning && onQueue && ( + {hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && ( (funct {shouldShowSendButton && ( (funct - Send + {defaultActionQueues ? "Queue" : "Send"} {sendKeys ? : null} diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index ab85b13eb..762f610e5 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -6,14 +6,18 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +export type SendBehavior = "interrupt" | "queue"; + export interface AppSettings { theme: "dark" | "light" | "auto"; manageBuiltInDaemon: boolean; + sendBehavior: SendBehavior; } export const DEFAULT_APP_SETTINGS: AppSettings = { theme: "auto", manageBuiltInDaemon: true, + sendBehavior: "interrupt", }; export interface UseAppSettingsReturn { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 1246ec6ac..7287f5ed4 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -15,7 +15,6 @@ import { RotateCw, Trash2, Server, - Palette, Keyboard, Stethoscope, Info, @@ -24,7 +23,7 @@ import { Blocks, Smartphone, } from "lucide-react-native"; -import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; @@ -77,7 +76,7 @@ import { StatusBadge } from "@/components/ui/status-badge"; type SettingsSectionId = | "hosts" - | "appearance" + | "general" | "shortcuts" | "integrations" | "providers" @@ -96,7 +95,7 @@ interface SettingsSectionDef { function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] { const sections: SettingsSectionDef[] = [ { id: "hosts", label: "Hosts", icon: Server }, - { id: "appearance", label: "Appearance", icon: Palette }, + { id: "general", label: "General", icon: Settings }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "permissions", label: "Permissions", icon: Shield }, ]; @@ -386,15 +385,20 @@ function HostsSection(props: HostsSectionProps) { ); } -interface AppearanceSectionProps { +interface GeneralSectionProps { settings: AppSettings; handleThemeChange: (theme: AppSettings["theme"]) => void; + handleSendBehaviorChange: (behavior: SendBehavior) => void; } -function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) { +function GeneralSection({ + settings, + handleThemeChange, + handleSendBehaviorChange, +}: GeneralSectionProps) { return ( - Appearance + General @@ -424,6 +428,23 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro ]} /> + + + Default send + + What happens when you press Enter while the agent is running + + + + ); @@ -614,7 +635,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) { interface SettingsSectionContentProps { sectionId: SettingsSectionId; hostsProps: HostsSectionProps; - appearanceProps: AppearanceSectionProps; + generalProps: GeneralSectionProps; providersProps: ProvidersSectionProps; diagnosticsProps: DiagnosticsSectionProps; aboutProps: AboutSectionProps; @@ -626,7 +647,7 @@ interface SettingsSectionContentProps { function SettingsSectionContent({ sectionId, hostsProps, - appearanceProps, + generalProps, providersProps, diagnosticsProps, aboutProps, @@ -637,8 +658,8 @@ function SettingsSectionContent({ switch (sectionId) { case "hosts": return ; - case "appearance": - return ; + case "general": + return ; case "shortcuts": return ; case "providers": @@ -1003,6 +1024,13 @@ export default function SettingsScreen() { [updateSettings], ); + const handleSendBehaviorChange = useCallback( + (behavior: SendBehavior) => { + void updateSettings({ sendBehavior: behavior }); + }, + [updateSettings], + ); + const handlePlaybackTest = useCallback(async () => { if (!voiceAudioEngine || isPlaybackTestRunning) { return; @@ -1069,9 +1097,10 @@ export default function SettingsScreen() { isMountedRef, }; - const appearanceProps: AppearanceSectionProps = { + const generalProps: GeneralSectionProps = { settings, handleThemeChange, + handleSendBehaviorChange, }; const diagnosticsProps: DiagnosticsSectionProps = { @@ -1092,7 +1121,7 @@ export default function SettingsScreen() { const sectionContentProps: Omit = { hostsProps, - appearanceProps, + generalProps, providersProps, diagnosticsProps, aboutProps, @@ -1901,6 +1930,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, + audioRowSubtitle: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + marginTop: theme.spacing[1], + }, providerActions: { flexDirection: "row", alignItems: "center", From bf91ea2cc987c6bcaf57465c744383bfeed2e48e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 10:23:24 +0700 Subject: [PATCH 18/32] feat(server): support image attachments in OpenCode agent prompts Adds logic to process file parts and convert image attachments into data URLs for the OpenCode provider, replacing the previous behavior that stripped non-text parts. --- .../agent/providers/opencode-agent.test.ts | 17 +++++ .../server/agent/providers/opencode-agent.ts | 72 ++++++++++++++++--- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 72f3fbdb1..d31648607 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -315,6 +315,23 @@ const hasOpenCode = isBinaryInstalled("opencode"); }); describe("OpenCode adapter context-window normalization", () => { + test("builds OpenCode file parts for image prompt blocks", () => { + expect( + __openCodeInternals.buildOpenCodePromptParts([ + { type: "text", text: "Describe this image." }, + { type: "image", mimeType: "image/png", data: "YWJjMTIz" }, + ]), + ).toEqual([ + { type: "text", text: "Describe this image." }, + { + type: "file", + mime: "image/png", + filename: "attachment-1.png", + url: "data:image/png;base64,YWJjMTIz", + }, + ]); + }); + test("preserves provider catalog context limit in model metadata", () => { const definition = __openCodeInternals.buildOpenCodeModelDefinition( { id: "openai", name: "OpenAI" }, diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 126003918..4cae22707 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -4,8 +4,10 @@ import { createOpencodeClient, type AssistantMessage as OpenCodeAssistantMessage, type Event as OpenCodeEvent, + type FilePartInput as OpenCodeFilePartInput, type OpencodeClient, type Part as OpenCodePart, + type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import net from "node:net"; import type { Logger } from "pino"; @@ -494,7 +496,66 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean { ].some((value) => typeof value === "number" && Number.isFinite(value)); } +function getOpenCodeAttachmentExtension(mimeType: string): string { + switch (mimeType) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + case "image/webp": + return "webp"; + case "image/gif": + return "gif"; + case "image/svg+xml": + return "svg"; + default: + return "bin"; + } +} + +function toOpenCodeDataUrl(mimeType: string, data: string): { mimeType: string; url: string } { + const match = data.match(/^data:([^;,]+);base64,(.+)$/); + if (match) { + return { + mimeType: match[1] ?? mimeType, + url: data, + }; + } + return { + mimeType, + url: `data:${mimeType};base64,${data}`, + }; +} + +function buildOpenCodePromptParts( + prompt: AgentPromptInput, +): Array { + if (typeof prompt === "string") { + return [{ type: "text", text: prompt }]; + } + let attachmentOrdinal = 0; + const output: Array = []; + for (const part of prompt) { + if (part.type === "text") { + output.push({ type: "text", text: part.text }); + continue; + } + attachmentOrdinal += 1; + const normalized = toOpenCodeDataUrl(part.mimeType, part.data); + output.push({ + type: "file", + mime: normalized.mimeType, + filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension( + normalized.mimeType, + )}`, + url: normalized.url, + }); + } + return output; +} + export const __openCodeInternals = { + buildOpenCodePromptParts, buildOpenCodeModelContextWindowLookup, buildOpenCodeModelDefinition, buildOpenCodeModelLookupKey, @@ -1436,7 +1497,7 @@ class OpenCodeAgentSession implements AgentSession { this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}; - const parts = this.buildPromptParts(prompt); + const parts = buildOpenCodePromptParts(prompt); const model = this.parseModel(this.config.model); const thinkingOptionId = this.config.thinkingOptionId; const effectiveVariant = thinkingOptionId ?? undefined; @@ -1862,15 +1923,6 @@ class OpenCodeAgentSession implements AgentSession { this.activeForegroundTurnId = null; } - private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> { - if (typeof prompt === "string") { - return [{ type: "text", text: prompt }]; - } - return prompt - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => ({ type: "text", text: p.text })); - } - private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null { const trimmed = text.trim(); if (!trimmed.startsWith("/") || trimmed.length <= 1) { From f0b09d90fd0cb73f278e2fae0a3aba3a14812795 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 10:31:33 +0700 Subject: [PATCH 19/32] =?UTF-8?q?fix(app):=20clean=20up=20QR=20scan=20scre?= =?UTF-8?q?en=20=E2=80=94=20remove=20instruction=20text,=20use=20accent=20?= =?UTF-8?q?color=20for=20crosshairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/app/pair-scan.tsx | 56 +++--------------------------- 1 file changed, 5 insertions(+), 51 deletions(-) diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index bf02d23ce..1594e2f20 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -6,8 +6,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult } from "expo-camera"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { NameHostModal } from "@/components/name-host-modal"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { connectToDaemon } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; @@ -61,7 +59,7 @@ const styles = StyleSheet.create((theme) => ({ position: "absolute", width: 36, height: 36, - borderColor: theme.colors.palette.blue[400], + borderColor: theme.colors.accent, }, cornerTL: { left: 0, @@ -148,28 +146,11 @@ export default function PairScanScreen() { const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null; const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null; const daemons = useHosts(); - const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations(); + const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); const [permission, requestPermission] = useCameraPermissions(); const [isPairing, setIsPairing] = useState(false); const lastScannedRef = useRef(null); - const [pendingNameHost, setPendingNameHost] = useState<{ - serverId: string; - hostname: string | null; - } | null>(null); - const pendingNameHostname = useSessionStore( - useCallback( - (state) => { - if (!pendingNameHost) return null; - return ( - state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? - pendingNameHost.hostname ?? - null - ); - }, - [pendingNameHost], - ), - ); const returnToSource = useCallback( (serverId: string) => { @@ -224,7 +205,6 @@ export default function PairScanScreen() { const handleScan = useCallback( async (result: BarcodeScanningResult) => { - if (pendingNameHost) return; if (isPairing) return; const offerUrl = extractOfferUrlFromScan(result); if (!offerUrl) return; @@ -248,7 +228,7 @@ export default function PairScanScreen() { return; } - const { client } = await connectToDaemon( + const { client, hostname } = await connectToDaemon( { id: "probe", type: "relay", @@ -259,13 +239,7 @@ export default function PairScanScreen() { ); await client.close().catch(() => undefined); - const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId); - const profile = await upsertDaemonFromOfferUrl(offerUrl); - - if (isNewHost) { - setPendingNameHost({ serverId: profile.serverId, hostname: null }); - return; - } + const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined); returnToSource(profile.serverId); } catch (error) { @@ -276,7 +250,7 @@ export default function PairScanScreen() { setIsPairing(false); } }, - [daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl], + [daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl], ); if (Platform.OS === "web") { @@ -307,25 +281,6 @@ export default function PairScanScreen() { return ( - {pendingNameHost ? ( - { - const serverId = pendingNameHost.serverId; - setPendingNameHost(null); - returnToSource(serverId); - }} - onSave={(label) => { - const serverId = pendingNameHost.serverId; - void renameHost(serverId, label).finally(() => { - setPendingNameHost(null); - returnToSource(serverId); - }); - }} - /> - ) : null} Scan QR @@ -359,7 +314,6 @@ export default function PairScanScreen() { - Point your camera at the pairing QR code. {isPairing ? ( Pairing… From 4925b947439953337d89ae0f6d3a3e8fc57db2dc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 10:32:01 +0700 Subject: [PATCH 20/32] Use advertised hostnames during pairing --- .../app/src/components/add-host-modal.tsx | 1 + .../app/src/components/name-host-modal.tsx | 117 ------------------ .../app/src/components/pair-link-modal.tsx | 2 +- .../app/src/components/welcome-screen.tsx | 63 +--------- packages/app/src/runtime/host-runtime.test.ts | 61 +++++++++ packages/app/src/runtime/host-runtime.ts | 24 ++-- packages/app/src/screens/settings-screen.tsx | 54 +------- 7 files changed, 85 insertions(+), 237 deletions(-) delete mode 100644 packages/app/src/components/name-host-modal.tsx diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 2a64ebd66..bd272fec2 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -218,6 +218,7 @@ export function AddHostModal({ const profile = await upsertDirectConnection({ serverId, endpoint, + label: hostname ?? undefined, }); onSaved?.({ profile, serverId, hostname, isNewHost }); diff --git a/packages/app/src/components/name-host-modal.tsx b/packages/app/src/components/name-host-modal.tsx deleted file mode 100644 index 3eb831c3b..000000000 --- a/packages/app/src/components/name-host-modal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; -import { Button } from "@/components/ui/button"; - -const styles = StyleSheet.create((theme) => ({ - helper: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - field: { - marginTop: theme.spacing[3], - gap: theme.spacing[2], - }, - label: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - input: { - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - color: theme.colors.foreground, - borderWidth: 1, - borderColor: theme.colors.border, - }, - actions: { - flexDirection: "row", - gap: theme.spacing[3], - marginTop: theme.spacing[4], - }, -})); - -export interface NameHostModalProps { - visible: boolean; - serverId: string; - hostname: string | null; - onSkip: () => void; - onSave: (label: string) => void; -} - -export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) { - const { theme } = useUnistyles(); - - const [label, setLabel] = useState(""); - const hasEditedRef = useRef(false); - - const suggested = (hostname?.trim() || serverId).trim(); - - useEffect(() => { - if (!visible) return; - setLabel(suggested); - hasEditedRef.current = false; - }, [suggested, visible]); - - useEffect(() => { - if (!visible) return; - if (hasEditedRef.current) return; - if (!hostname) return; - const trimmed = label.trim(); - if (trimmed.length === 0 || trimmed === serverId) { - setLabel(hostname.trim()); - } - }, [hostname, label, serverId, visible]); - - const handleChange = useCallback((value: string) => { - hasEditedRef.current = true; - setLabel(value); - }, []); - - const handleSave = useCallback(() => { - const trimmed = label.trim(); - if (!trimmed) { - onSkip(); - return; - } - onSave(trimmed); - }, [label, onSave, onSkip]); - - return ( - - Optional. You can rename this later in Settings. - - - Label - - - - - - - - - ); -} diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index b0c3d30e8..5abd5a2ad 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -146,7 +146,7 @@ export function PairLinkModal({ await client.close().catch(() => undefined); const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId); - const profile = await upsertDaemonFromOfferUrl(raw); + const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined); onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost }); handleClose(); } catch (error) { diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 6f51ef1df..f88fef460 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -8,14 +8,11 @@ import type { HostProfile } from "@/types/host-connection"; import { getHostRuntimeStore, isHostRuntimeConnected, - useHostMutations, useHostRuntimeSnapshot, useHosts, } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; -import { NameHostModal } from "./name-host-modal"; import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { buildHostRootRoute } from "@/utils/host-routes"; @@ -237,41 +234,19 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); - const { renameHost } = useHostMutations(); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const [isDirectOpen, setIsDirectOpen] = useState(false); const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false); - const [pendingNameHost, setPendingNameHost] = useState<{ - serverId: string; - hostname: string | null; - } | null>(null); - const [pendingRedirectServerId, setPendingRedirectServerId] = useState(null); const hosts = useHosts(); const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); - const pendingNameHostname = useSessionStore( - useCallback( - (state) => { - if (!pendingNameHost) return null; - return ( - state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? - pendingNameHost.hostname ?? - null - ); - }, - [pendingNameHost], - ), - ); useEffect(() => { if (!anyOnlineServerId) { return; } - if (pendingNameHost) { - return; - } router.replace(buildHostRootRoute(anyOnlineServerId)); - }, [anyOnlineServerId, pendingNameHost, router]); + }, [anyOnlineServerId, router]); const finishOnboarding = useCallback( (serverId: string) => { @@ -396,13 +371,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { setIsDirectOpen(false)} - onSaved={({ profile, serverId, hostname, isNewHost }) => { + onSaved={({ profile, serverId }) => { onHostAdded?.(profile); - setPendingRedirectServerId(serverId); - if (isNewHost) { - setPendingNameHost({ serverId, hostname }); - return; - } finishOnboarding(serverId); }} /> @@ -410,38 +380,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { setIsPasteLinkOpen(false)} - onSaved={({ profile, serverId, hostname, isNewHost }) => { + onSaved={({ profile, serverId }) => { onHostAdded?.(profile); - setPendingRedirectServerId(serverId); - if (isNewHost) { - setPendingNameHost({ serverId, hostname }); - return; - } finishOnboarding(serverId); }} /> - - {pendingNameHost && pendingRedirectServerId ? ( - { - const serverId = pendingRedirectServerId; - setPendingNameHost(null); - setPendingRedirectServerId(null); - finishOnboarding(serverId); - }} - onSave={(label) => { - const serverId = pendingRedirectServerId; - void renameHost(pendingNameHost.serverId, label).finally(() => { - setPendingNameHost(null); - setPendingRedirectServerId(null); - finishOnboarding(serverId); - }); - }} - /> - ) : null} ); } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index dcebc6118..77aa1892e 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -5,6 +5,7 @@ import type { FetchAgentsEntry, FetchAgentsOptions, } from "@server/client/daemon-client"; +import type { ConnectionOffer } from "@server/shared/connection-offer"; import type { HostConnection, HostProfile } from "@/types/host-connection"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { @@ -189,6 +190,17 @@ function makeHost(input?: Partial): HostProfile { }; } +function makeOffer(input?: Partial): ConnectionOffer { + return { + v: 2, + serverId: input?.serverId ?? "srv_offer", + daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer", + relay: { + endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443", + }, + }; +} + function makeDeps( latencyByConnectionId: Record, createdClients: FakeDaemonClient[], @@ -1295,4 +1307,53 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + + it("uses the advertised hostname when adding a relay host from a pairing offer", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.label).toBe("mbp"); + + store.syncHosts([]); + }); + + it("keeps a custom host label when re-pairing with an advertised hostname", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertRelayConnection({ + serverId: "srv_offer", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test_offer", + label: "Custom name", + }); + + await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.label).toBe("Custom name"); + + store.syncHosts([]); + }); }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 2ad3648e3..864d69068 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1287,15 +1287,22 @@ export class HostRuntimeStore { }); } - async upsertConnectionFromOffer(offer: ConnectionOffer): Promise { + async upsertConnectionFromOffer( + offer: ConnectionOffer, + label?: string, + ): Promise { return this.upsertRelayConnection({ serverId: offer.serverId, relayEndpoint: offer.relay.endpoint, daemonPublicKeyB64: offer.daemonPublicKeyB64, + label, }); } - async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise { + async upsertConnectionFromOfferUrl( + offerUrlOrFragment: string, + label?: string, + ): Promise { const marker = "#offer="; const idx = offerUrlOrFragment.indexOf(marker); if (idx === -1) { @@ -1307,7 +1314,7 @@ export class HostRuntimeStore { } const payload = decodeOfferFragmentPayload(encoded); const offer = ConnectionOfferSchema.parse(payload); - return this.upsertConnectionFromOffer(offer); + return this.upsertConnectionFromOffer(offer, label); } async addConnectionFromListenAndWaitForOnline(input: { @@ -1956,8 +1963,11 @@ export interface HostMutations { daemonPublicKeyB64: string; label?: string; }) => Promise; - upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise; - upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise; + upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise; + upsertConnectionFromOfferUrl: ( + offerUrlOrFragment: string, + label?: string, + ) => Promise; renameHost: (serverId: string, label: string) => Promise; removeHost: (serverId: string) => Promise; removeConnection: (serverId: string, connectionId: string) => Promise; @@ -1969,8 +1979,8 @@ export function useHostMutations(): HostMutations { () => ({ upsertDirectConnection: (input) => store.upsertDirectConnection(input), upsertRelayConnection: (input) => store.upsertRelayConnection(input), - upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer), - upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url), + upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), + upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label), renameHost: (serverId, label) => store.renameHost(serverId, label), removeHost: (serverId) => store.removeHost(serverId), removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId), diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 7287f5ed4..f6e7d2b7a 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -41,7 +41,6 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; -import { NameHostModal } from "@/components/name-host-modal"; import { Button } from "@/components/ui/button"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { @@ -96,20 +95,20 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio const sections: SettingsSectionDef[] = [ { id: "hosts", label: "Hosts", icon: Server }, { id: "general", label: "General", icon: Settings }, - { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "permissions", label: "Permissions", icon: Shield }, ]; if (context.isDesktopApp) { sections.push( + { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "integrations", label: "Integrations", icon: Puzzle }, { id: "pair-device", label: "Pair device", icon: Smartphone }, { id: "daemon", label: "Daemon", icon: Settings }, + { id: "providers", label: "Providers", icon: Blocks }, ); } sections.push( - { id: "providers", label: "Providers", icon: Blocks }, { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "about", label: "About", icon: Info }, ); @@ -203,10 +202,6 @@ interface HostsSectionProps { goBackToAddConnectionMethods: () => void; setIsDirectHostVisible: (visible: boolean) => void; setIsPasteLinkVisible: (visible: boolean) => void; - pendingNameHost: { serverId: string; hostname: string | null } | null; - setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void; - pendingNameHostname: string | null; - renameHost: (serverId: string, label: string) => Promise; pendingRemoveHost: HostProfile | null; setPendingRemoveHost: (host: HostProfile | null) => void; isRemovingHost: boolean; @@ -289,38 +284,14 @@ function HostsSection(props: HostsSectionProps) { visible={props.isDirectHostVisible} onClose={props.closeAddConnectionFlow} onCancel={props.goBackToAddConnectionMethods} - onSaved={({ serverId, hostname, isNewHost }) => { - if (isNewHost) { - props.setPendingNameHost({ serverId, hostname }); - } - }} /> { - if (isNewHost) { - props.setPendingNameHost({ serverId, hostname }); - } - }} /> - {props.pendingNameHost ? ( - props.setPendingNameHost(null)} - onSave={(label) => { - void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => { - props.setPendingNameHost(null); - }); - }} - /> - ) : null} - {props.pendingRemoveHost ? ( (null); const [pendingRemoveHost, setPendingRemoveHost] = useState(null); const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); @@ -905,19 +872,6 @@ export default function SettingsScreen() { const editingDaemonLive = editingServerId ? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null) : null; - const pendingNameHostname = useSessionStore( - useCallback( - (state) => { - if (!pendingNameHost) return null; - return ( - state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? - pendingNameHost.hostname ?? - null - ); - }, - [pendingNameHost], - ), - ); useEffect(() => { return () => { @@ -1077,10 +1031,6 @@ export default function SettingsScreen() { goBackToAddConnectionMethods, setIsDirectHostVisible, setIsPasteLinkVisible, - pendingNameHost, - setPendingNameHost, - pendingNameHostname, - renameHost, pendingRemoveHost, setPendingRemoveHost, isRemovingHost, From 57eafda6ef41f4c083b90ac520726ea3581149e4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 11:18:49 +0700 Subject: [PATCH 21/32] fix(app): fix BottomSheetTextInput crash on iPad model selector On iPad, Combobox uses the desktop Modal path (no BottomSheet context) since the breakpoint is "md"+, but ProviderSearchInput was checking Platform.OS === "web" to choose the input component. This caused BottomSheetTextInput to render outside a BottomSheet on iPad. Align the check with Combobox's own isMobile breakpoint logic. --- packages/app/src/components/combined-model-selector.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index ffb0ef6df..2c90b8425 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -9,7 +9,7 @@ import { type GestureResponderEvent, } from "react-native"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { ArrowLeft, ChevronDown, @@ -348,7 +348,9 @@ function ProviderSearchInput({ }) { const { theme } = useUnistyles(); const inputRef = useRef(null); - const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const InputComponent = isMobile ? BottomSheetTextInput : TextInput; useEffect(() => { if (autoFocus && Platform.OS === "web" && inputRef.current) { From fcf2c1448547967b1fef30ba4667cc19fe104941 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 13:57:44 +0700 Subject: [PATCH 22/32] docs(changelog): add 0.1.51 release notes --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a4959f0..0d232b44f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.1.51 - 2026-04-09 + +### Added +- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts. +- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed. +- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message. + +### Fixed +- Model selector no longer crashes on iPad. +- Pairing now uses the correct hostname, fixing connection failures on some network setups. +- OpenCode agents show the correct terminal state and refresh models reliably. +- Follow-up messages to agents that just finished a turn now work correctly. +- Commands now load properly for Pi agents. +- Internal debug output no longer appears in Claude agent timelines. +- QR scan screen cleaned up with simpler visuals. + ## 0.1.50 - 2026-04-07 ### Added From b27c1b072933fde7e6973f6c765781158d5d9412 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 13:58:59 +0700 Subject: [PATCH 23/32] chore(release): cut 0.1.51 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a651b7c8..259bcd36f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.51-rc.1", + "version": "0.1.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.51-rc.1", + "version": "0.1.51", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.51-rc.1", + "version": "0.1.51", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.51-rc.1", - "@getpaseo/highlight": "0.1.51-rc.1", - "@getpaseo/server": "0.1.51-rc.1", + "@getpaseo/expo-two-way-audio": "0.1.51", + "@getpaseo/highlight": "0.1.51", + "@getpaseo/server": "0.1.51", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35032,11 +35032,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.51-rc.1", + "version": "0.1.51", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.51-rc.1", - "@getpaseo/server": "0.1.51-rc.1", + "@getpaseo/relay": "0.1.51", + "@getpaseo/server": "0.1.51", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35077,11 +35077,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.51-rc.1", + "version": "0.1.51", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.51-rc.1", - "@getpaseo/server": "0.1.51-rc.1", + "@getpaseo/cli": "0.1.51", + "@getpaseo/server": "0.1.51", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35115,7 +35115,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.51-rc.1", + "version": "0.1.51", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35316,7 +35316,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.51-rc.1", + "version": "0.1.51", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35342,7 +35342,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.51-rc.1", + "version": "0.1.51", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35358,14 +35358,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.51-rc.1", + "version": "0.1.51", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.51-rc.1", - "@getpaseo/relay": "0.1.51-rc.1", + "@getpaseo/highlight": "0.1.51", + "@getpaseo/relay": "0.1.51", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35764,7 +35764,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.51-rc.1", + "version": "0.1.51", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index ff92d0303..220c754b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.51-rc.1", + "version": "0.1.51", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index f6b83e25d..e14fa095c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.51-rc.1", + "version": "0.1.51", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.51-rc.1", - "@getpaseo/highlight": "0.1.51-rc.1", - "@getpaseo/server": "0.1.51-rc.1", + "@getpaseo/expo-two-way-audio": "0.1.51", + "@getpaseo/highlight": "0.1.51", + "@getpaseo/server": "0.1.51", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2723bfc91..017f44bca 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.51-rc.1", + "version": "0.1.51", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.51-rc.1", - "@getpaseo/server": "0.1.51-rc.1", + "@getpaseo/relay": "0.1.51", + "@getpaseo/server": "0.1.51", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1db3658cc..20caca0ea 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.51-rc.1", + "version": "0.1.51", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.51-rc.1", - "@getpaseo/server": "0.1.51-rc.1", + "@getpaseo/cli": "0.1.51", + "@getpaseo/server": "0.1.51", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index dee1749af..9b0a62de4 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.51-rc.1", + "version": "0.1.51", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 3770a8065..a4477d7d9 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.51-rc.1", + "version": "0.1.51", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 224edc31b..06a6563e3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.51-rc.1", + "version": "0.1.51", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index b95b805f1..55f247b0c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.51-rc.1", + "version": "0.1.51", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.51-rc.1", - "@getpaseo/relay": "0.1.51-rc.1", + "@getpaseo/highlight": "0.1.51", + "@getpaseo/relay": "0.1.51", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/website/package.json b/packages/website/package.json index 3c58655f8..a42e6b38c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.51-rc.1", + "version": "0.1.51", "private": true, "type": "module", "scripts": { From 30842398e7d4be6d76aa330af959bf07b5805a87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 9 Apr 2026 07:00:28 +0000 Subject: [PATCH 24/32] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 35aed99d4..db9dd06ca 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0="; + npmDepsHash = "sha256-gyCcVTlgLk8+G/OtwZT/GomN2lYe7uEcDoLp1ZQoc5M="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). From 914a5dc6ae6584a0ec7b1833630e00b3c54ca32a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 00:20:42 +0700 Subject: [PATCH 25/32] ci: add comprehensive CI workflow --- .github/workflows/ci.yml | 171 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..04b70c0bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,171 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Check formatting + run: npx biome format . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Build highlight dependency + run: npm run build --workspace=@getpaseo/highlight + + - name: Build relay dependency + run: npm run build --workspace=@getpaseo/relay + + - name: Typecheck all packages + run: npm run typecheck + + server-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Fetch origin/main (worktree tests) + run: git fetch --no-tags origin main:refs/remotes/origin/main + + - name: Install dependencies + run: npm install + + - name: Build highlight dependency + run: npm run build --workspace=@getpaseo/highlight + + - name: Build relay dependency + run: npm run build --workspace=@getpaseo/relay + + - name: Run server tests + run: npm run test --workspace=@getpaseo/server + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + app-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Run app unit tests + run: npm run test --workspace=@getpaseo/app + + playwright: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Build highlight dependency + run: npm run build --workspace=@getpaseo/highlight + + - name: Build relay dependency + run: npm run build --workspace=@getpaseo/relay + + - name: Run Playwright E2E tests + run: npm run test:e2e --workspace=@getpaseo/app + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + - name: Upload test artifacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-results + path: | + packages/app/test-results/ + packages/app/playwright-report/ + retention-days: 7 + + relay-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Build relay + run: npm run build --workspace=@getpaseo/relay + + - name: Run relay tests + run: npm run test --workspace=@getpaseo/relay + + cli-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Build highlight dependency + run: npm run build --workspace=@getpaseo/highlight + + - name: Run CLI tests + run: npm run test --workspace=@getpaseo/cli + env: + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0' + PASEO_DICTATION_ENABLED: '0' + PASEO_VOICE_MODE_ENABLED: '0' From 2683dae5f96c611a957202a4d7023ff97689ed12 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 19:42:59 +0700 Subject: [PATCH 26/32] Auto-download desktop updates before prompting --- .../src/desktop/updates/desktop-updates.ts | 2 + .../app/src/desktop/updates/update-banner.tsx | 2 +- .../updates/use-desktop-app-updater.ts | 32 +++- packages/app/src/screens/settings-screen.tsx | 2 +- packages/desktop/src/features/auto-updater.ts | 172 +++++++++++++----- 5 files changed, 159 insertions(+), 51 deletions(-) diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 04d429dec..36e97e9a1 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -4,6 +4,7 @@ import { invokeDesktopCommand } from "@/desktop/electron/invoke"; export interface DesktopAppUpdateCheckResult { hasUpdate: boolean; + readyToInstall: boolean; currentVersion: string | null; latestVersion: string | null; body: string | null; @@ -76,6 +77,7 @@ export async function checkDesktopAppUpdate(): Promise { + if (!isDesktopApp || status !== "pending") { + return undefined; + } + + const intervalId = setInterval(() => { + void checkForUpdates({ silent: true }); + }, PENDING_RECHECK_MS); + + return () => { + clearInterval(intervalId); + }; + }, [checkForUpdates, isDesktopApp, status]); + const installUpdate = useCallback(async () => { if (!isDesktopApp) { return null; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index f6e7d2b7a..f87abed98 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -807,7 +807,7 @@ function DesktopAppUpdateRow() { {statusText} {availableUpdate?.latestVersion ? ( - New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)} + Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} ) : null} {errorMessage ? {errorMessage} : null} diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index d1c1dca8f..61ecee1d9 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -7,6 +7,7 @@ import { autoUpdater, type UpdateInfo } from "electron-updater"; export type AppUpdateCheckResult = { hasUpdate: boolean; + readyToInstall: boolean; currentVersion: string; latestVersion: string; body: string | null; @@ -24,19 +25,84 @@ export type AppUpdateInstallResult = { // --------------------------------------------------------------------------- let cachedUpdateInfo: UpdateInfo | null = null; +let downloadedUpdateVersion: string | null = null; let downloading = false; +let autoUpdaterConfigured = false; // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- function configureAutoUpdater(): void { - // Don't auto-download — the user triggers install explicitly. - autoUpdater.autoDownload = false; + // Download updates in the background and only prompt once they are ready to install. + autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; // Suppress built-in dialogs; the renderer handles UI. autoUpdater.autoRunAppAfterInstall = true; + + if (autoUpdaterConfigured) { + return; + } + + autoUpdaterConfigured = true; + + autoUpdater.on("update-available", (info) => { + cachedUpdateInfo = info; + downloadedUpdateVersion = null; + downloading = true; + }); + + autoUpdater.on("update-downloaded", (info) => { + cachedUpdateInfo = info; + downloadedUpdateVersion = info.version; + downloading = false; + }); + + autoUpdater.on("update-not-available", () => { + cachedUpdateInfo = null; + downloadedUpdateVersion = null; + downloading = false; + }); + + autoUpdater.on("error", (error) => { + downloading = false; + console.error("[auto-updater] Updater event failed:", error); + }); +} + +function isReadyToInstallVersion(version: string): boolean { + return downloadedUpdateVersion === version; +} + +function buildCheckResult(input: { + currentVersion: string; + hasUpdate: boolean; + readyToInstall: boolean; + info?: UpdateInfo | null; +}): AppUpdateCheckResult { + const { currentVersion, hasUpdate, readyToInstall, info } = input; + + return { + hasUpdate, + readyToInstall, + currentVersion, + latestVersion: info?.version ?? currentVersion, + body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null, + date: typeof info?.releaseDate === "string" ? info.releaseDate : null, + }; +} + +function scheduleQuitAndInstall(onBeforeQuit?: () => Promise): void { + // Use a short delay to allow the renderer to receive the response. + setTimeout(async () => { + try { + if (onBeforeQuit) await onBeforeQuit(); + autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); + } catch (error) { + console.error("[auto-updater] quitAndInstall failed:", error); + } + }, 1500); } // --------------------------------------------------------------------------- @@ -45,28 +111,34 @@ function configureAutoUpdater(): void { export async function checkForAppUpdate(currentVersion: string): Promise { if (!app.isPackaged) { - return { - hasUpdate: false, + return buildCheckResult({ currentVersion, - latestVersion: currentVersion, - body: null, - date: null, - }; + hasUpdate: false, + readyToInstall: false, + }); } configureAutoUpdater(); + const cachedVersion = cachedUpdateInfo?.version ?? null; + if (cachedVersion && cachedVersion !== currentVersion) { + return buildCheckResult({ + currentVersion, + hasUpdate: true, + readyToInstall: isReadyToInstallVersion(cachedVersion), + info: cachedUpdateInfo, + }); + } + try { const result = await autoUpdater.checkForUpdates(); if (!result || !result.updateInfo) { - return { - hasUpdate: false, + return buildCheckResult({ currentVersion, - latestVersion: currentVersion, - body: null, - date: null, - }; + hasUpdate: false, + readyToInstall: false, + }); } const info = result.updateInfo; @@ -75,24 +147,31 @@ export async function checkForAppUpdate(currentVersion: string): Promise { - try { - if (onBeforeQuit) await onBeforeQuit(); - autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); - } catch (error) { - console.error("[auto-updater] quitAndInstall failed:", error); - } - }, 1500); + downloadedUpdateVersion = readyVersion; + downloading = false; + scheduleQuitAndInstall(onBeforeQuit); return { installed: true, - version: cachedUpdateInfo.version, + version: readyVersion, message: "Update downloaded. The app will restart shortly.", }; } catch (error) { From 17bd036359e34d78fd4f4bf979517e7eeb324913 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 19:46:51 +0700 Subject: [PATCH 27/32] Persist draft agent feature preferences --- .../app/src/components/agent-status-bar.tsx | 54 +++++++---- .../app/src/hooks/feature-preferences.test.ts | 53 ++++++++++ packages/app/src/hooks/feature-preferences.ts | 60 ++++++++++++ .../app/src/hooks/use-agent-form-state.ts | 35 ++++--- .../app/src/hooks/use-draft-agent-features.ts | 97 +++++++++---------- .../src/hooks/use-form-preferences.test.ts | 35 +++++++ .../app/src/hooks/use-form-preferences.ts | 18 +++- 7 files changed, 263 insertions(+), 89 deletions(-) create mode 100644 packages/app/src/hooks/feature-preferences.test.ts create mode 100644 packages/app/src/hooks/feature-preferences.ts diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 5954ad2af..cc2f22917 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -962,13 +962,14 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat return; } void updatePreferences( - mergeProviderPreferences({ - preferences, - provider: agent.provider, - updates: { - model: modelId, - }, - }), + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + model: modelId, + }, + }), ).catch((error) => { console.warn("[AgentStatusBar] persist model preference failed", error); }); @@ -978,7 +979,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat }} favoriteKeys={favoriteKeys} onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[AgentStatusBar] toggle favorite model failed", error); }); }} @@ -991,16 +992,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat const activeModelId = modelSelection.activeModelId; if (activeModelId) { void updatePreferences( - mergeProviderPreferences({ - preferences, - provider: agent.provider, - updates: { - model: activeModelId, - thinkingByModel: { - [activeModelId]: thinkingOptionId, + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + model: activeModelId, + thinkingByModel: { + [activeModelId]: thinkingOptionId, + }, }, - }, - }), + }), ).catch((error) => { console.warn("[AgentStatusBar] persist thinking preference failed", error); }); @@ -1014,6 +1016,20 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat if (!client) { return; } + void updatePreferences( + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + featureValues: { + [featureId]: value, + }, + }, + }), + ).catch((error) => { + console.warn("[AgentStatusBar] persist feature preference failed", error); + }); void client.setAgentFeature(agentId, featureId, value).catch((error) => { console.warn("[AgentStatusBar] setAgentFeature failed", error); }); @@ -1083,7 +1099,7 @@ export function DraftAgentStatusBar({ onSelect={onSelectProviderAndModel} favoriteKeys={favoriteKeys} onToggleFavorite={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); }); }} @@ -1129,7 +1145,7 @@ export function DraftAgentStatusBar({ isModelLoading={isAllModelsLoading} favoriteKeys={favoriteKeys} onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); }); }} diff --git a/packages/app/src/hooks/feature-preferences.test.ts b/packages/app/src/hooks/feature-preferences.test.ts new file mode 100644 index 000000000..7cfca13a6 --- /dev/null +++ b/packages/app/src/hooks/feature-preferences.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { resolveFeatureValues } from "./feature-preferences"; + +describe("feature-preferences", () => { + const features = [ + { + type: "toggle" as const, + id: "fast_mode", + label: "Fast", + value: false, + }, + { + type: "toggle" as const, + id: "plan_mode", + label: "Plan", + value: false, + }, + ]; + + it("restores persisted values for available features", () => { + expect( + resolveFeatureValues({ + features, + persistedFeatureValues: { + fast_mode: true, + unknown_feature: true, + }, + localFeatureValues: {}, + }), + ).toEqual({ + fast_mode: true, + }); + }); + + it("prefers local values over persisted values", () => { + expect( + resolveFeatureValues({ + features, + persistedFeatureValues: { + fast_mode: true, + plan_mode: false, + }, + localFeatureValues: { + fast_mode: false, + }, + }), + ).toEqual({ + fast_mode: false, + plan_mode: false, + }); + }); +}); diff --git a/packages/app/src/hooks/feature-preferences.ts b/packages/app/src/hooks/feature-preferences.ts new file mode 100644 index 000000000..8fc3f1aaf --- /dev/null +++ b/packages/app/src/hooks/feature-preferences.ts @@ -0,0 +1,60 @@ +import type { AgentFeature } from "@server/server/agent/agent-sdk-types"; + +export function pruneFeatureValues( + featureValues: Record, + features: AgentFeature[], +): Record { + const allowedFeatureIds = new Set(features.map((feature) => feature.id)); + let changed = false; + const next: Record = {}; + + for (const [featureId, value] of Object.entries(featureValues)) { + if (!allowedFeatureIds.has(featureId)) { + changed = true; + continue; + } + next[featureId] = value; + } + + return changed ? next : featureValues; +} + +export function applyFeatureValues( + features: AgentFeature[], + featureValues: Record, +): AgentFeature[] { + if (Object.keys(featureValues).length === 0) { + return features; + } + + return features.map((feature) => { + if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { + return feature; + } + + return { + ...feature, + value: featureValues[feature.id], + } as AgentFeature; + }); +} + +export function resolveFeatureValues(args: { + features: AgentFeature[]; + persistedFeatureValues: Record; + localFeatureValues: Record; +}): Record { + const next: Record = {}; + + for (const feature of args.features) { + if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) { + next[feature.id] = args.localFeatureValues[feature.id]; + continue; + } + if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) { + next[feature.id] = args.persistedFeatureValues[feature.id]; + } + } + + return next; +} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 09e0ae2fd..fcf939101 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -651,30 +651,29 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); const modelId = resolvedModel?.id ?? formState.model; - const nextPreferences = mergeProviderPreferences({ - preferences: preferences ?? {}, - provider: formState.provider, - updates: { - model: modelId || undefined, - mode: formState.modeId || undefined, - ...(modelId && formState.thinkingOptionId - ? { - thinkingByModel: { - [modelId]: formState.thinkingOptionId, - }, - } - : {}), - } satisfies Partial, - }); - - await updatePreferences(nextPreferences); + await updatePreferences((current) => + mergeProviderPreferences({ + preferences: current, + provider: formState.provider, + updates: { + model: modelId || undefined, + mode: formState.modeId || undefined, + ...(modelId && formState.thinkingOptionId + ? { + thinkingByModel: { + [modelId]: formState.thinkingOptionId, + }, + } + : {}), + } satisfies Partial, + }), + ); }, [ availableModels, formState.model, formState.modeId, formState.provider, formState.thinkingOptionId, - preferences?.providerPreferences, updatePreferences, ]); diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts index eaef93d6f..3f9a4dce4 100644 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -1,50 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { - AgentFeature, AgentProvider, AgentSessionConfig, } from "@server/server/agent/agent-sdk-types"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; - -function pruneFeatureValues( - featureValues: Record, - features: AgentFeature[], -): Record { - const allowedFeatureIds = new Set(features.map((feature) => feature.id)); - let changed = false; - const next: Record = {}; - - for (const [featureId, value] of Object.entries(featureValues)) { - if (!allowedFeatureIds.has(featureId)) { - changed = true; - continue; - } - next[featureId] = value; - } - - return changed ? next : featureValues; -} - -function applyFeatureValues( - features: AgentFeature[], - featureValues: Record, -): AgentFeature[] { - if (Object.keys(featureValues).length === 0) { - return features; - } - - return features.map((feature) => { - if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { - return feature; - } - - return { - ...feature, - value: featureValues[feature.id], - } as AgentFeature; - }); -} +import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; +import { + applyFeatureValues, + pruneFeatureValues, + resolveFeatureValues, +} from "./feature-preferences"; type DraftFeatureConfig = Pick< AgentSessionConfig, @@ -60,10 +26,15 @@ export function useDraftAgentFeatures(input: { thinkingOptionId: string | null | undefined; }) { const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input; - const [featureValues, setFeatureValues] = useState>({}); + const [localFeatureValues, setLocalFeatureValues] = useState>({}); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const { preferences, updatePreferences } = useFormPreferences(); const normalizedCwd = cwd?.trim() || ""; + const persistedFeatureValues = useMemo( + () => preferences.providerPreferences?.[provider]?.featureValues ?? {}, + [preferences.providerPreferences, provider], + ); const draftConfig = useMemo(() => { if (!normalizedCwd) { @@ -102,28 +73,56 @@ export function useDraftAgentFeatures(input: { return payload.features ?? []; }, }); + const availableFeatures = featuresQuery.data ?? []; + const featureValues = useMemo( + () => + resolveFeatureValues({ + features: availableFeatures, + persistedFeatureValues, + localFeatureValues, + }), + [availableFeatures, localFeatureValues, persistedFeatureValues], + ); const features = useMemo(() => { - return applyFeatureValues(featuresQuery.data ?? [], featureValues); - }, [featureValues, featuresQuery.data]); + return applyFeatureValues(availableFeatures, featureValues); + }, [availableFeatures, featureValues]); useEffect(() => { - const next = pruneFeatureValues(featureValues, features); - if (next !== featureValues) { - setFeatureValues(next); + setLocalFeatureValues({}); + }, [provider]); + + useEffect(() => { + const next = pruneFeatureValues(localFeatureValues, availableFeatures); + if (next !== localFeatureValues) { + setLocalFeatureValues(next); } - }, [featureValues, features]); + }, [availableFeatures, localFeatureValues]); const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; const setFeatureValue = useCallback((featureId: string, value: unknown) => { - setFeatureValues((current) => { + setLocalFeatureValues((current) => { if (Object.is(current[featureId], value)) { return current; } return { ...current, [featureId]: value }; }); - }, []); + void updatePreferences( + (current) => + mergeProviderPreferences({ + preferences: current, + provider, + updates: { + featureValues: { + [featureId]: value, + }, + }, + }), + ).catch((error) => { + console.warn("[useDraftAgentFeatures] persist feature preference failed", error); + }); + }, [provider, updatePreferences]); return { features, diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index 55bb8fc56..4dd277420 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -59,6 +59,41 @@ describe("mergeProviderPreferences", () => { }, }); }); + + it("merges feature values without dropping existing entries", () => { + expect( + mergeProviderPreferences({ + preferences: { + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + featureValues: { + fast_mode: true, + }, + }, + }, + }, + provider: "codex", + updates: { + featureValues: { + plan_mode: true, + }, + }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + featureValues: { + fast_mode: true, + plan_mode: true, + }, + }, + }, + }); + }); }); describe("favorite model preferences", () => { diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index ff04c8e84..cd14ebf66 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -25,6 +25,7 @@ const providerPreferencesSchema = z.object({ model: z.string().optional(), mode: z.string().optional(), thinkingByModel: z.record(z.string()).optional(), + featureValues: z.record(z.unknown()).optional(), }); const formPreferencesSchema = z.object({ @@ -53,7 +54,9 @@ async function loadFormPreferences(): Promise { export interface UseFormPreferencesReturn { preferences: FormPreferences; isLoading: boolean; - updatePreferences: (updates: Partial) => Promise; + updatePreferences: ( + updates: Partial | ((current: FormPreferences) => FormPreferences), + ) => Promise; } export function mergeProviderPreferences(args: { @@ -71,6 +74,13 @@ export function mergeProviderPreferences(args: { ...existing.thinkingByModel, ...updates.thinkingByModel, }; + const nextFeatureValues = + updates.featureValues === undefined + ? existing.featureValues + : { + ...existing.featureValues, + ...updates.featureValues, + }; return { ...preferences, @@ -81,6 +91,7 @@ export function mergeProviderPreferences(args: { ...existing, ...updates, ...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}), + ...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}), }, }, }; @@ -133,11 +144,12 @@ export function useFormPreferences(): UseFormPreferencesReturn { const preferences = data ?? DEFAULT_FORM_PREFERENCES; const updatePreferences = useCallback( - async (updates: Partial) => { + async (updates: Partial | ((current: FormPreferences) => FormPreferences)) => { const prev = queryClient.getQueryData(FORM_PREFERENCES_QUERY_KEY) ?? DEFAULT_FORM_PREFERENCES; - const next = { ...prev, ...updates }; + const next = + typeof updates === "function" ? updates(prev) : { ...prev, ...updates }; queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); }, From 29af07e383a5a8b85e740b9371878d7556021968 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 20:05:07 +0700 Subject: [PATCH 28/32] Fix live agent refresh without persistence --- .../src/server/daemon-client.e2e.test.ts | 150 ++++++++++++++++++ packages/server/src/server/session.ts | 6 +- 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index a83c41cb3..1e0086d7b 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -14,6 +14,14 @@ import { } from "./test-utils/index.js"; import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js"; import { chunkPcm16, parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js"; +import type { + AgentClient, + AgentPersistenceHandle, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, +} from "./agent/agent-sdk-types.js"; const openaiApiKey = process.env.OPENAI_API_KEY ?? null; @@ -94,6 +102,116 @@ function waitForSignal( }); } +class NonPersistentReloadSession implements AgentSession { + readonly provider = "claude" as const; + readonly id = null; + readonly capabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + } as const; + + constructor(private readonly onClose: () => void) {} + + async run(): Promise { + return { + sessionId: "non-persistent", + finalText: "", + timeline: [], + }; + } + + async startTurn(): Promise<{ turnId: string }> { + return { turnId: "non-persistent-turn" }; + } + + subscribe(_callback: (event: AgentStreamEvent) => void): () => void { + return () => undefined; + } + + async *streamHistory(): AsyncGenerator { + return; + } + + async getRuntimeInfo() { + return { + provider: "claude" as const, + sessionId: null, + model: null, + modeId: null, + }; + } + + async getAvailableModes(): Promise<[]> { + return []; + } + + async getCurrentMode(): Promise { + return null; + } + + async setMode(_modeId: string): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence(): AgentPersistenceHandle | null { + return null; + } + + async interrupt(): Promise {} + + async close(): Promise { + this.onClose(); + } +} + +class NonPersistentReloadClient implements AgentClient { + readonly provider = "claude" as const; + readonly capabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + } as const; + createSessionCalls = 0; + resumeSessionCalls = 0; + closeCalls = 0; + + async isAvailable(): Promise { + return true; + } + + async createSession(_config: AgentSessionConfig): Promise { + this.createSessionCalls += 1; + return new NonPersistentReloadSession(() => { + this.closeCalls += 1; + }); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + _overrides?: Partial, + ): Promise { + this.resumeSessionCalls += 1; + return new NonPersistentReloadSession(() => { + this.closeCalls += 1; + }); + } + + async listModels() { + return []; + } +} + describe("daemon client E2E", () => { let ctx: DaemonTestContext; @@ -279,6 +397,38 @@ describe("daemon client E2E", () => { } }, 120000); + test("refresh_agent rebuilds a live agent even when it has no persistence handle", async () => { + const cwd = tmpCwd(); + const client = new NonPersistentReloadClient(); + const localCtx = await createDaemonTestContext({ + agentClients: { + claude: client, + }, + }); + + try { + const created = await localCtx.client.createAgent({ + config: { + provider: "claude", + cwd, + }, + }); + + expect(client.createSessionCalls).toBe(1); + expect(client.resumeSessionCalls).toBe(0); + expect(client.closeCalls).toBe(0); + + await localCtx.client.refreshAgent(created.id); + + expect(client.createSessionCalls).toBe(2); + expect(client.resumeSessionCalls).toBe(0); + expect(client.closeCalls).toBe(1); + } finally { + await localCtx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("resume_agent auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2c5c6c3f5..eafa8e6eb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3095,11 +3095,7 @@ export class Session { const existing = this.agentManager.getAgent(agentId); if (existing) { await this.interruptAgentIfRunning(agentId); - if (existing.persistence) { - snapshot = await this.agentManager.reloadAgentSession(agentId); - } else { - snapshot = existing; - } + snapshot = await this.agentManager.reloadAgentSession(agentId); } else { const record = await this.agentStorage.get(agentId); if (!record) { From c1ebb8c915ecc394a15e084be2e040d7edb6b2f0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 23:44:38 +0700 Subject: [PATCH 29/32] Refactor form factor check to reactive useIsCompactFormFactor --- docs/DATA_MODEL.md | 428 ++++++++++++++++++ packages/app/src/app/_layout.tsx | 4 +- .../src/components/adaptive-modal-sheet.tsx | 7 +- .../app/src/components/add-host-modal.tsx | 5 +- .../app/src/components/agent-input-area.tsx | 9 +- packages/app/src/components/agent-list.tsx | 5 +- .../app/src/components/agent-stream-view.tsx | 7 +- .../components/combined-model-selector.tsx | 6 +- .../app/src/components/explorer-sidebar.tsx | 4 +- .../app/src/components/file-explorer-pane.tsx | 5 +- packages/app/src/components/file-pane.tsx | 5 +- packages/app/src/components/git-diff-pane.tsx | 5 +- .../src/components/headers/menu-header.tsx | 4 +- .../src/components/headers/screen-header.tsx | 4 +- packages/app/src/components/left-sidebar.tsx | 4 +- packages/app/src/components/message.tsx | 5 +- .../app/src/components/pair-link-modal.tsx | 5 +- .../app/src/components/question-form-card.tsx | 5 +- .../src/components/sidebar-workspace-list.tsx | 6 +- packages/app/src/components/terminal-pane.tsx | 4 +- packages/app/src/components/toast-host.tsx | 5 +- packages/app/src/components/ui/combobox.tsx | 5 +- .../app/src/components/ui/context-menu.tsx | 5 +- packages/app/src/constants/layout.ts | 19 +- .../explorer-sidebar-animation-context.tsx | 4 +- .../contexts/sidebar-animation-context.tsx | 4 +- .../app/src/hooks/use-checkout-diff-query.ts | 4 +- .../src/hooks/use-checkout-status-query.ts | 4 +- .../src/screens/agent/draft-agent-screen.tsx | 4 +- .../app/src/screens/open-project-screen.tsx | 4 +- packages/app/src/screens/settings-screen.tsx | 4 +- .../screens/workspace/workspace-screen.tsx | 4 +- 32 files changed, 515 insertions(+), 78 deletions(-) create mode 100644 docs/DATA_MODEL.md diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 000000000..86fb7306a --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,428 @@ +# Data Model + +Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility. + +All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`). + +--- + +## Directory layout + +``` +$PASEO_HOME/ +├── config.json # Daemon configuration +├── agents/ +│ └── {project-dir}/ +│ └── {agentId}.json # One file per agent +├── schedules/ +│ └── {scheduleId}.json # One file per schedule +├── chat/ +│ └── rooms.json # All rooms + messages +├── loops/ +│ └── loops.json # All loop records +├── projects/ +│ ├── projects.json # Project registry +│ └── workspaces.json # Workspace registry +└── push-tokens.json # Expo push notification tokens +``` + +--- + +## 1. Agent Record + +**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json` + +Each agent is stored as a separate JSON file, grouped by project directory. + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | UUID, primary key | +| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | +| `cwd` | `string` | Working directory the agent operates in | +| `createdAt` | `string` (ISO 8601) | Creation timestamp | +| `updatedAt` | `string` (ISO 8601) | Last update timestamp | +| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | +| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | +| `title` | `string?` | User-visible title | +| `labels` | `Record` | Key-value labels (default `{}`) | +| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | +| `lastModeId` | `string?` | Last active mode ID | +| `config` | `SerializableConfig?` | Agent session configuration (see below) | +| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | +| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | +| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | +| `requiresAttention` | `boolean?` | Whether the agent needs user attention | +| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | +| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | +| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | + +### Nested: SerializableConfig + +| Field | Type | Description | +|---|---|---| +| `title` | `string?` | Configured title | +| `modeId` | `string?` | Configured mode | +| `model` | `string?` | Configured model | +| `thinkingOptionId` | `string?` | Thinking/reasoning level | +| `featureValues` | `Record?` | Feature preference overrides | +| `extra` | `Record?` | Provider-specific config | +| `systemPrompt` | `string?` | Custom system prompt | +| `mcpServers` | `Record?` | MCP server configurations | + +### Nested: RuntimeInfo + +| Field | Type | Description | +|---|---|---| +| `provider` | `string` | Active provider | +| `sessionId` | `string?` | Active session ID | +| `model` | `string?` | Active model | +| `thinkingOptionId` | `string?` | Active thinking option | +| `modeId` | `string?` | Active mode | +| `extra` | `Record?` | Provider-specific runtime data | + +### Nested: PersistenceHandle + +| Field | Type | Description | +|---|---|---| +| `provider` | `string` | Provider that owns the session | +| `sessionId` | `string` | Session ID for resumption | +| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) | +| `metadata` | `Record?` | Extra metadata | + +### Nested: AgentFeature (discriminated union on `type`) + +**Toggle:** + +| Field | Type | +|---|---| +| `type` | `"toggle"` | +| `id` | `string` | +| `label` | `string` | +| `description` | `string?` | +| `tooltip` | `string?` | +| `icon` | `string?` | +| `value` | `boolean` | + +**Select:** + +| Field | Type | +|---|---| +| `type` | `"select"` | +| `id` | `string` | +| `label` | `string` | +| `description` | `string?` | +| `tooltip` | `string?` | +| `icon` | `string?` | +| `value` | `string?` | +| `options` | `AgentSelectOption[]` | + +--- + +## 2. Daemon Configuration + +**Path:** `$PASEO_HOME/config.json` + +Single file, validated with `PersistedConfigSchema`. + +``` +{ + version: 1, + daemon: { + listen: "127.0.0.1:6767", + allowedHosts: true | string[], + mcp: { enabled: boolean }, + cors: { allowedOrigins: string[] }, + relay: { enabled: boolean, endpoint: string, publicEndpoint: string } + }, + app: { + baseUrl: string + }, + providers: { + openai: { apiKey: string }, + local: { modelsDir: string } + }, + agents: { + providers: { + [provider: string]: { + command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] }, + env: Record + } + } + }, + features: { + dictation: { enabled, stt: { provider, model, confidenceThreshold } }, + voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } } + }, + log: { + level, format, + console: { level, format }, + file: { level, path, rotate: { maxSize, maxFiles } } + } +} +``` + +All fields are optional with sensible defaults. + +--- + +## 3. Schedule + +**Path:** `$PASEO_HOME/schedules/{id}.json` + +One file per schedule. ID is 8 hex characters. + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | 8-char hex ID | +| `name` | `string?` | Human-readable name | +| `prompt` | `string` | The prompt to send | +| `cadence` | `ScheduleCadence` | Timing (see below) | +| `target` | `ScheduleTarget` | What to run (see below) | +| `status` | `"active" \| "paused" \| "completed"` | Current state | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution | +| `lastRunAt` | `string?` (ISO 8601) | Last execution time | +| `pausedAt` | `string?` (ISO 8601) | When paused | +| `expiresAt` | `string?` (ISO 8601) | Auto-expire time | +| `maxRuns` | `number?` | Max executions before completing | +| `runs` | `ScheduleRun[]` | Execution history | + +### Nested: ScheduleCadence (discriminated union on `type`) + +- `{ type: "every", everyMs: number }` — interval in milliseconds +- `{ type: "cron", expression: string }` — cron expression + +### Nested: ScheduleTarget (discriminated union on `type`) + +- `{ type: "agent", agentId: string }` — send to existing agent +- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent + +### Nested: ScheduleRun + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | Run ID | +| `scheduledFor` | `string` (ISO 8601) | Intended execution time | +| `startedAt` | `string` (ISO 8601) | | +| `endedAt` | `string?` (ISO 8601) | | +| `status` | `"running" \| "succeeded" \| "failed"` | | +| `agentId` | `string?` (UUID) | Agent used for this run | +| `output` | `string?` | Agent output text | +| `error` | `string?` | Error message if failed | + +--- + +## 4. Chat + +**Path:** `$PASEO_HOME/chat/rooms.json` + +Single file containing all rooms and messages. + +```json +{ + "rooms": [ ... ], + "messages": [ ... ] +} +``` + +### ChatRoom + +| Field | Type | Description | +|---|---|---| +| `id` | `string` (UUID) | | +| `name` | `string` | Unique room name (case-insensitive) | +| `purpose` | `string?` | Room description | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | Updated on each new message | + +### ChatMessage + +| Field | Type | Description | +|---|---|---| +| `id` | `string` (UUID) | | +| `roomId` | `string` | FK to ChatRoom.id | +| `authorAgentId` | `string` | Agent ID of the author | +| `body` | `string` | Message text (supports `@mentions`) | +| `replyToMessageId` | `string?` | FK to another ChatMessage.id | +| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs | +| `createdAt` | `string` (ISO 8601) | | + +--- + +## 5. Loop + +**Path:** `$PASEO_HOME/loops/loops.json` + +Single file containing an array of all loop records. + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | 8-char UUID prefix | +| `name` | `string?` | Human-readable name | +| `prompt` | `string` | Worker prompt | +| `cwd` | `string` | Working directory | +| `provider` | `string` | Default provider | +| `model` | `string?` | Default model | +| `workerProvider` | `string?` | Override provider for workers | +| `workerModel` | `string?` | Override model for workers | +| `verifierProvider` | `string?` | Override provider for verifiers | +| `verifierModel` | `string?` | Override model for verifiers | +| `verifyPrompt` | `string?` | LLM verification prompt | +| `verifyChecks` | `string[]` | Shell commands to run as checks | +| `archive` | `boolean` | Whether to archive worker agents after use | +| `sleepMs` | `number` | Delay between iterations (ms) | +| `maxIterations` | `number?` | Cap on iterations | +| `maxTimeMs` | `number?` | Total time budget (ms) | +| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `startedAt` | `string` (ISO 8601) | | +| `completedAt` | `string?` (ISO 8601) | | +| `stopRequestedAt` | `string?` (ISO 8601) | | +| `iterations` | `LoopIteration[]` | | +| `logs` | `LoopLogEntry[]` | | +| `nextLogSeq` | `number` | Monotonic log sequence counter | +| `activeIteration` | `number?` | Currently executing iteration index | +| `activeWorkerAgentId` | `string?` | Currently running worker agent | +| `activeVerifierAgentId` | `string?` | Currently running verifier agent | + +### Nested: LoopIteration + +| Field | Type | Description | +|---|---|---| +| `index` | `number` | 1-based iteration index | +| `workerAgentId` | `string?` | Agent ID of the worker | +| `workerStartedAt` | `string` (ISO 8601) | | +| `workerCompletedAt` | `string?` (ISO 8601) | | +| `verifierAgentId` | `string?` | Agent ID of the verifier | +| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | +| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | | +| `failureReason` | `string?` | | +| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results | +| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result | + +### Nested: LoopLogEntry + +| Field | Type | +|---|---| +| `seq` | `number` (monotonic) | +| `timestamp` | `string` (ISO 8601) | +| `iteration` | `number?` | +| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` | +| `level` | `"info" \| "error"` | +| `text` | `string` | + +### Nested: LoopVerifyCheckResult + +| Field | Type | +|---|---| +| `command` | `string` | +| `exitCode` | `number` | +| `passed` | `boolean` | +| `stdout` | `string` | +| `stderr` | `string` | +| `startedAt` | `string` (ISO 8601) | +| `completedAt` | `string` (ISO 8601) | + +### Nested: LoopVerifyPromptResult + +| Field | Type | +|---|---| +| `passed` | `boolean` | +| `reason` | `string` | +| `verifierAgentId` | `string?` | +| `startedAt` | `string` (ISO 8601) | +| `completedAt` | `string` (ISO 8601) | + +--- + +## 6. Project Registry + +**Path:** `$PASEO_HOME/projects/projects.json` + +Array of project records. + +| Field | Type | Description | +|---|---|---| +| `projectId` | `string` | Primary key | +| `rootPath` | `string` | Filesystem root of the project | +| `kind` | `"git" \| "non_git"` | | +| `displayName` | `string` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | + +--- + +## 7. Workspace Registry + +**Path:** `$PASEO_HOME/projects/workspaces.json` + +Array of workspace records. A workspace is a specific working directory within a project. + +| Field | Type | Description | +|---|---|---| +| `workspaceId` | `string` | Primary key | +| `projectId` | `string` | FK to Project.projectId | +| `cwd` | `string` | Filesystem path | +| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | +| `displayName` | `string` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | + +--- + +## 8. Push Token Store + +**Path:** `$PASEO_HOME/push-tokens.json` + +```json +{ + "tokens": ["ExponentPushToken[...]", ...] +} +``` + +Simple set of Expo push notification tokens. No schema validation — just an array of strings. + +--- + +## Client-side stores (App) + +These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem. + +### Draft Store + +**AsyncStorage key:** `paseo-drafts` (version 2) + +```typescript +{ + drafts: Record, + createModalDraft: DraftRecord | null +} +``` + +### Attachment Store (Web) + +**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments` + +Stores binary attachment blobs keyed by attachment ID. + +### AttachmentMetadata + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | Unique attachment ID | +| `mimeType` | `string` | MIME type | +| `storageType` | `string` | Storage backend identifier | +| `storageKey` | `string` | Key within the storage backend | +| `createdAt` | `number` | Epoch ms | +| `fileName` | `string?` | Original filename | +| `byteSize` | `number?` | Size in bytes | diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 75de47889..03483af99 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -57,7 +57,7 @@ import { HorizontalScrollProvider, useHorizontalScrollOptional, } from "@/contexts/horizontal-scroll-context"; -import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout"; +import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { CommandCenter } from "@/components/command-center"; import { ProjectPickerModal } from "@/components/project-picker-modal"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; @@ -372,7 +372,7 @@ function AppContainer({ const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); - const isCompactLayout = isCompactFormFactor(); + const isCompactLayout = useIsCompactFormFactor(); const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; useEffect(() => { diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index a3ccd5f28..c5f3edbff 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -3,7 +3,8 @@ import type { ReactNode } from "react"; import { createPortal } from "react-dom"; import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import type { TextInputProps } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { BottomSheetModal, @@ -119,7 +120,7 @@ export function AdaptiveModalSheet({ testID, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const sheetRef = useRef(null); const dismissingForVisibilityRef = useRef(false); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); @@ -239,7 +240,7 @@ export function AdaptiveModalSheet({ */ export const AdaptiveTextInput = forwardRef( function AdaptiveTextInput(props, ref) { - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); if (isMobile) { return ; diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index bd272fec2..7e3bd9d29 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { Alert, Text, TextInput, View } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; @@ -151,7 +152,7 @@ export function AddHostModal({ const { theme } = useUnistyles(); const daemons = useHosts(); const { upsertDirectConnection } = useHostMutations(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const hostInputRef = useRef(null); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 8f549b05c..69d9d7e4d 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -1,6 +1,7 @@ import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native"; import { useState, useEffect, useRef, useCallback } from "react"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useShallow } from "zustand/shallow"; import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; import Animated from "react-native-reanimated"; @@ -154,10 +155,8 @@ export function AgentInputArea({ const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const isDesktopWebBreakpoint = - Platform.OS === "web" && - UnistylesRuntime.breakpoint !== "xs" && - UnistylesRuntime.breakpoint !== "sm"; + const isMobile = useIsCompactFormFactor(); + const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile; const messagePlaceholder = isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index dd2b1af18..63384bb64 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -10,7 +10,8 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useCallback, useMemo, useState, type ReactElement } from "react"; import { router } from "expo-router"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; @@ -214,7 +215,7 @@ export function AgentList({ const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const actionClient = useSessionStore((state) => actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 2fc15dba1..d96d69a9c 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -9,7 +9,8 @@ import { useState, } from "react"; import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import Animated, { @@ -109,7 +110,7 @@ const AgentStreamViewComponent = forwardRef(null); const { theme } = useUnistyles(); const router = useRouter(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( () => resolveStreamRenderStrategy({ @@ -707,7 +708,7 @@ function PermissionRequestCard({ client: DaemonClient | null; }) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const { request } = permission; const isPlanRequest = request.kind === "plan"; diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 2c90b8425..745e0369a 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -9,7 +9,8 @@ import { type GestureResponderEvent, } from "react-native"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { ArrowLeft, ChevronDown, @@ -348,8 +349,7 @@ function ProviderSearchInput({ }) { const { theme } = useUnistyles(); const inputRef = useRef(null); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const InputComponent = isMobile ? BottomSheetTextInput : TextInput; useEffect(() => { diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 1ab3eb94c..fcfe9a140 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -20,7 +20,7 @@ import { type ExplorerTab, } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; -import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout"; +import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { GitDiffPane } from "./git-diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; @@ -48,7 +48,7 @@ export function ExplorerSidebar({ const { theme } = useUnistyles(); const isScreenFocused = useIsFocused(); const insets = useSafeAreaInsets(); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 44761930d..983fd1147 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -10,7 +10,8 @@ import { Platform, } from "react-native"; import { Gesture } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import Animated, { cancelAnimation, Easing, @@ -89,7 +90,7 @@ export function FileExplorerPane({ onOpenFile, }: FileExplorerPaneProps) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const daemons = useHosts(); diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index b92e204e3..1b6e429b6 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -8,7 +8,8 @@ import { View, Platform, } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; @@ -241,7 +242,7 @@ export function FilePane({ workspaceRoot: string; filePath: string; }) { - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 5ffe4f365..41e3dcf23 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -21,7 +21,8 @@ import { TextStyle, } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { AlignJustify, Archive, @@ -607,7 +608,7 @@ type DiffFlatItem = export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const canUseSplitLayout = Platform.OS === "web" && !isMobile; const router = useRouter(); diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index f7e5a8904..09234adc7 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { HeaderToggleButton } from "./header-toggle-button"; import { usePanelStore } from "@/stores/panel-store"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { getShortcutOs } from "@/utils/shortcut-platform"; interface MenuHeaderProps { @@ -44,7 +44,7 @@ export function SidebarMenuToggle({ nativeID = "menu-button", }: SidebarMenuToggleProps = {}) { const { theme } = useUnistyles(); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index a544efdab..4a5a28b22 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -6,7 +6,7 @@ import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE, - isCompactFormFactor, + useIsCompactFormFactor, } from "@/constants/layout"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; @@ -26,7 +26,7 @@ interface ScreenHeaderProps { export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const padding = useWindowControlsPadding("header"); // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 653c58942..5d9b27e63 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -50,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, - isCompactFormFactor, + useIsCompactFormFactor, } from "@/constants/layout"; import { buildHostSessionsRoute, @@ -118,7 +118,7 @@ export const LeftSidebar = memo(function LeftSidebar({ const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const isCompactLayout = isCompactFormFactor(); + const isCompactLayout = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 978c21745..cd7161ba9 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -41,7 +41,8 @@ import { Scissors, MicVocal, } from "lucide-react-native"; -import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import Animated, { Easing, cancelAnimation, @@ -1822,7 +1823,7 @@ export const ToolCall = memo(function ToolCall({ const [isExpanded, setIsExpanded] = useState(false); // Check if we're on mobile (use bottom sheet) or desktop (inline expand) - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const effectiveDetail = useMemo(() => { if (detail) { diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 5abd5a2ad..178a36b23 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { Alert, Text, View } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Link } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; @@ -66,7 +67,7 @@ export function PairLinkModal({ const { theme } = useUnistyles(); const daemons = useHosts(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const [offerUrl, setOfferUrl] = useState(""); const [isSaving, setIsSaving] = useState(false); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 771b8d4fa..d6fa504a6 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from "react"; import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; -import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; @@ -63,7 +64,7 @@ const IS_WEB = Platform.OS === "web"; export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const questions = parseQuestions(permission.request.input); const [selections, setSelections] = useState>>({}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index bbae09de9..5ae598445 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -44,7 +44,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; -import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout"; +import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; @@ -713,7 +713,7 @@ function ProjectHeaderRow({ }: ProjectHeaderRowProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); - const isMobileBreakpoint = isCompactFormFactor(); + const isMobileBreakpoint = useIsCompactFormFactor(); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const toast = useToast(); @@ -1606,7 +1606,7 @@ export function SidebarWorkspaceList({ listFooterComponent, parentGestureRef, }: SidebarWorkspaceListProps) { - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const isNative = Platform.OS !== "web"; const pathname = usePathname(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index df1f27e70..af7ef4aca 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -21,7 +21,7 @@ import { import { usePanelStore } from "@/stores/panel-store"; import { toXtermTheme } from "@/utils/to-xterm-theme"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; interface TerminalPaneProps { serverId: string; @@ -92,7 +92,7 @@ export function TerminalPane({ const isAppVisible = useAppVisible(); const { theme } = useUnistyles(); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const openAgentList = usePanelStore((state) => state.openAgentList); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 2ff21f875..ca1f04575 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { createPortal } from "react-dom"; import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { @@ -181,7 +182,7 @@ export function ToastViewport({ return null; } - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topOffset = diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 9d709cbd7..012d181a7 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -11,7 +11,8 @@ import { StatusBar, useWindowDimensions, } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { BottomSheetModal, BottomSheetScrollView, @@ -261,7 +262,7 @@ export function Combobox({ anchorRef, children, }: ComboboxProps): ReactElement { - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const isDesktopAboveSearch = !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index db3196e03..fef7b5435 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -27,7 +27,8 @@ import { type ViewStyle, } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -347,7 +348,7 @@ export function ContextMenuContent({ testID?: string; }>): ReactElement | null { const context = useContextMenuContext("ContextMenuContent"); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; const bottomSheetRef = useRef(null); diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 2a220ffa9..4a02ad47e 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1,5 +1,5 @@ import { Platform } from "react-native"; -import { UnistylesRuntime } from "react-native-unistyles"; +import { useUnistyles } from "react-native-unistyles"; import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; export const FOOTER_HEIGHT = 75; @@ -62,16 +62,13 @@ export function getIsElectronRuntime(): boolean { return result; } -export function isCompactFormFactor(): boolean { - return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; -} - -export function isDesktopFormFactor(): boolean { - return !isCompactFormFactor(); -} - -export function isTouchDesktopFormFactor(): boolean { - return Platform.OS !== "web" && isDesktopFormFactor(); +/** + * Reactive hook — re-renders the component when the breakpoint changes. + * Always use this instead of reading UnistylesRuntime.breakpoint directly. + */ +export function useIsCompactFormFactor(): boolean { + const { rt } = useUnistyles(); + return rt.breakpoint === "xs" || rt.breakpoint === "sm"; } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index 80d5c191d..2d1271d4c 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re import { useWindowDimensions } from "react-native"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { getRightSidebarAnimationTargets, @@ -29,7 +29,7 @@ const ExplorerSidebarAnimationContext = createContext state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index f315eee6c..cbc0e9f24 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -10,7 +10,7 @@ import { import { useWindowDimensions } from "react-native"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { getLeftSidebarAnimationTargets, @@ -36,7 +36,7 @@ const SidebarAnimationContext = createContext state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index e563a8220..3af185c53 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useId, useMemo } from "react"; -import { UnistylesRuntime } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; @@ -60,7 +60,7 @@ export function useCheckoutDiffQuery({ const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const explorerTab = usePanelStore((state) => state.explorerTab); diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index 3aed5b223..7ff55e5c5 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; -import { UnistylesRuntime } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutStatusResponse } from "@server/shared/messages"; @@ -32,7 +32,7 @@ function fetchCheckoutStatus( export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const explorerTab = usePanelStore((state) => state.explorerTab); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 69f7c0c05..534bf759b 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -40,7 +40,7 @@ import { } from "@/runtime/host-runtime"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; -import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout"; +import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { WelcomeScreen } from "@/components/welcome-screen"; import type { Agent } from "@/contexts/session-context"; import { encodeImages } from "@/utils/encode-images"; @@ -235,7 +235,7 @@ function DraftAgentScreenContent({ isCreateFlow: true, onlineServerIds, }); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 65e64902a..0d6859172 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; -import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; export function OpenProjectScreen({ serverId }: { serverId: string }) { @@ -17,7 +17,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false); const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0); - const isCompactLayout = isCompactFormFactor(); + const isCompactLayout = useIsCompactFormFactor(); useEffect(() => { if (!isCompactLayout) { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index f87abed98..507a5a220 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -63,7 +63,7 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { getProviderIcon } from "@/components/provider-icons"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; @@ -1014,7 +1014,7 @@ export default function SettingsScreen() { } }, [isPlaybackTestRunning, voiceAudioEngine]); - const isCompactLayout = isCompactFormFactor(); + const isCompactLayout = useIsCompactFormFactor(); const sections = getSettingsSections({ isDesktopApp }); const hostsProps: HostsSectionProps = { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 186d81283..ab53f23ac 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -116,7 +116,7 @@ import { closeBulkWorkspaceTabs, } from "@/screens/workspace/workspace-bulk-close"; import { findAdjacentPane } from "@/utils/split-navigation"; -import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; +import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; const TERMINALS_QUERY_STALE_TIME = 5_000; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; @@ -591,7 +591,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const insets = useSafeAreaInsets(); const mainBackgroundColor = theme.colors.surfaceWorkspace; const toast = useToast(); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; From edfd2564a3f47103f3e56f4e68f17e6bfbd7edf5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 10:01:39 +0700 Subject: [PATCH 30/32] Change default file log level from trace to debug Trace-level logging emits two entries per streaming token per session, causing significant memory growth and GC pressure under multi-workspace use. Fixes #227. --- packages/server/src/server/logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/server/logger.ts b/packages/server/src/server/logger.ts index 4b7afd5b2..981ceb798 100644 --- a/packages/server/src/server/logger.ts +++ b/packages/server/src/server/logger.ts @@ -50,7 +50,7 @@ const LOG_LEVEL_PRIORITIES: Record = { const DEFAULT_CONSOLE_LEVEL: LogLevel = "info"; const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty"; -const DEFAULT_FILE_LEVEL: LogLevel = "trace"; +const DEFAULT_FILE_LEVEL: LogLevel = "debug"; const DEFAULT_FILE_ROTATE_SIZE = "10m"; const DEFAULT_FILE_ROTATE_MAX_FILES = 2; const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log"; From 9f09a19a093fc40a1c331ea55219d2196ea07e7e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 10:05:13 +0700 Subject: [PATCH 31/32] Gate Claude SDK stream trace logs behind PASEO_CLAUDE_DEBUG Per-token trace logs from the query pump fire twice per streaming event and contribute to memory growth under heavy use. Gate them behind an opt-in env var so they're available for debugging but silent by default. --- packages/server/.env.example | 3 ++ .../server/agent/providers/claude-agent.ts | 40 +++++++++++-------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/server/.env.example b/packages/server/.env.example index f9771f268..960466c82 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -18,3 +18,6 @@ PASEO_LISTEN=127.0.0.1:6767 # Debug recordings (dictation + STT input + TTS output) # When enabled, recordings are saved under `${cwd}/.debug/recordings/` PASEO_DICTATION_DEBUG=1 + +# Enable verbose Claude SDK stream logging (trace-level per-token logs) +PASEO_CLAUDE_DEBUG=1 diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 2218a0fc5..aa32dfa22 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1047,6 +1047,8 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { }; } +const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1"; + export class ClaudeAgentClient implements AgentClient { readonly provider: "claude" = "claude"; readonly capabilities = CLAUDE_CAPABILITIES; @@ -2454,15 +2456,17 @@ class ClaudeAgentSession implements AgentSession { while (!this.closed && this.query === activeQuery) { try { for await (const message of activeQuery) { - this.logger.trace( - { - claudeSessionId: this.claudeSessionId, - messageType: message.type, - messageSubtype: "subtype" in message ? message.subtype : undefined, - messageUuid: "uuid" in message ? message.uuid : undefined, - }, - "Claude query pump: raw SDK message", - ); + if (claudeDebug) { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + messageType: message.type, + messageSubtype: "subtype" in message ? message.subtype : undefined, + messageUuid: "uuid" in message ? message.uuid : undefined, + }, + "Claude query pump: raw SDK message", + ); + } consecutiveInterruptAbortRecoveries = 0; if (await this.handleMissingResumedConversation(message, activeQuery)) { return; @@ -2538,14 +2542,16 @@ class ClaudeAgentSession implements AgentSession { const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null; const identifiers = readEventIdentifiers(message); - this.logger.trace( - { - claudeSessionId: this.claudeSessionId, - messageType: message.type, - turnId, - }, - "Claude query pump: SDK message", - ); + if (claudeDebug) { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + messageType: message.type, + turnId, + }, + "Claude query pump: SDK message", + ); + } const messageEvents = this.translateMessageToEvents(message, { suppressAssistantText: true, From 201eb6a6719415a18c224a3a8713d76645d50414 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 10:11:55 +0700 Subject: [PATCH 32/32] Self-contained desktop dev script with worktree isolation dev:desktop now launches its own Metro on a random port + Electron, no dependency on a shared :8081. In worktrees, Electron gets a unique userData path and dock badge so multiple instances run side-by-side. --- packages/desktop/package.json | 2 +- packages/desktop/scripts/dev.sh | 28 ++++++++++++++++++++++++ packages/desktop/src/main.ts | 38 +++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100755 packages/desktop/scripts/dev.sh diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 20caca0ea..0704b3e01 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", - "dev": "npm run build:main && wait-on tcp:8081 && electron .", + "dev": "./scripts/dev.sh", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh new file mode 100755 index 000000000..a4756af9a --- /dev/null +++ b/packages/desktop/scripts/dev.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)" +ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)" + +# Build the Electron main process +npm run build:main + +# Get a random available port for Metro +EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port") +export EXPO_PORT + +echo "══════════════════════════════════════════════════════" +echo " Paseo Desktop Dev" +echo "══════════════════════════════════════════════════════" +echo " Metro: http://localhost:${EXPO_PORT}" +echo "══════════════════════════════════════════════════════" + +# Launch Metro + Electron together, kill both on exit +"$ROOT_DIR/node_modules/.bin/concurrently" \ + --kill-others \ + --names "metro,electron" \ + --prefix-colors "magenta,cyan" \ + "cd '$APP_DIR' && npx expo start --port $EXPO_PORT" \ + "$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'" diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index e581a3600..c57654955 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -8,6 +8,7 @@ inheritLoginShellEnv(); import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron"; import { registerDaemonManager } from "./daemon/daemon-manager.js"; import { @@ -38,6 +39,37 @@ const APP_SCHEME = "paseo"; const OPEN_PROJECT_EVENT = "paseo:event:open-project"; app.setName("Paseo"); +// In dev mode, detect git worktrees and isolate each instance so multiple +// Electron windows can run side-by-side (separate userData = separate lock). +let devWorktreeName: string | null = null; +if (!app.isPackaged) { + try { + const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf-8", + timeout: 3000, + }).trim(); + devWorktreeName = path.basename(topLevel); + // Main checkout (e.g. "paseo") gets default userData — only worktrees diverge. + const commonDir = path.resolve( + topLevel, + execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: topLevel, + encoding: "utf-8", + timeout: 3000, + }).trim(), + ); + const isWorktree = path.resolve(topLevel, ".git") !== commonDir; + if (isWorktree) { + app.setPath("userData", path.join(app.getPath("appData"), `Paseo-${devWorktreeName}`)); + log.info("[worktree] isolated userData for worktree:", devWorktreeName); + } else { + devWorktreeName = null; + } + } catch { + devWorktreeName = null; + } +} + // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). // Must run before app.whenReady(). @@ -127,7 +159,9 @@ async function createMainWindow(): Promise { const iconPath = getWindowIconPath(); const systemTheme = resolveSystemWindowTheme(); + const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo"; const mainWindow = new BrowserWindow({ + title, width: 1200, height: 800, show: false, @@ -144,6 +178,10 @@ async function createMainWindow(): Promise { }, }); + if (devWorktreeName) { + app.dock?.setBadge(devWorktreeName); + } + setupWindowResizeEvents(mainWindow); setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow);