From f21f86165358bbae5545abf7621fe9dd2e33b982 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 17:08:16 +0700 Subject: [PATCH 01/33] Fix CLI test import for resolveProviderAndModel The function moved to utils/provider-model.ts but the test still imported from commands/agent/run.ts. --- packages/cli/tests/21-run-output-schema-helper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/tests/21-run-output-schema-helper.test.ts b/packages/cli/tests/21-run-output-schema-helper.test.ts index 6db80fd66..398b16175 100644 --- a/packages/cli/tests/21-run-output-schema-helper.test.ts +++ b/packages/cli/tests/21-run-output-schema-helper.test.ts @@ -2,10 +2,10 @@ import assert from "node:assert"; import { - resolveProviderAndModel, resolveStructuredResponseMessage, type StructuredResponseTimelineClient, } from "../src/commands/agent/run.ts"; +import { resolveProviderAndModel } from "../src/utils/provider-model.ts"; type TimelineEntry = { item: { From df0ce60a350cd0e88ffceacc79fb00bfb785b9f2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 17:10:54 +0700 Subject: [PATCH 02/33] Fix formatting in synced loader components --- .../src/components/sidebar-workspace-list.tsx | 18 ++++++++++++++++-- .../workspace/workspace-tab-presentation.tsx | 9 ++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 6b9eca15a..5347eaa85 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -236,7 +236,14 @@ function WorkspaceStatusIndicator({ if (shouldShowSyncedLoader) { return ( - + ); } @@ -340,7 +347,14 @@ function ProjectLeadingVisual({ if (shouldShowSyncedLoader) { return ( - + ); } diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 3cd9b817e..457300c5b 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -134,7 +134,14 @@ export function WorkspaceTabIcon({ if (shouldShowLoader) { return ( - + ); } From 61c36b4e2299c5c713a1074c59da4ff19c350687 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 17:46:21 +0700 Subject: [PATCH 03/33] fix: use cached snapshot for checkout_pr_status_request The handler was calling refresh() before every getSnapshot(), forcing a full git + gh subprocess reload (~1.2s). The filesystem watcher already keeps the snapshot current, so just read the cache directly. --- packages/server/src/server/session.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c393669d8..8ef1fb197 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4718,7 +4718,6 @@ export class Session { const { cwd, requestId } = msg; try { - await this.workspaceGitService.refresh(cwd, { priority: "high" }); const snapshot = await this.workspaceGitService.getSnapshot(cwd); this.emit({ type: "checkout_pr_status_response", From 5a836eb170657ec690ef65edf75dc604fd8f5185 Mon Sep 17 00:00:00 2001 From: Qiao Wang Date: Tue, 14 Apr 2026 19:26:47 +0800 Subject: [PATCH 04/33] Allow dev on windows machine (#357) --- package.json | 2 + packages/desktop/package.json | 1 + packages/desktop/scripts/dev.ps1 | 36 ++++++++++++++++++ scripts/dev.ps1 | 65 ++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 packages/desktop/scripts/dev.ps1 create mode 100644 scripts/dev.ps1 diff --git a/package.json b/package.json index 8a65ef12a..904ab52fb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ ], "scripts": { "dev": "./scripts/dev.sh", + "dev:win": "powershell ./scripts/dev.ps1", "dev:server": "npm run dev --workspace=@getpaseo/server", "dev:app": "npm run start --workspace=@getpaseo/app", "dev:website": "npm run dev --workspace=@getpaseo/website", @@ -36,6 +37,7 @@ "ios": "npm run ios --workspace=@getpaseo/app", "web": "npm run web --workspace=@getpaseo/app", "dev:desktop": "npm run dev --workspace=@getpaseo/desktop", + "dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop", "build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop", "cli": "npx tsx packages/cli/src/index.js", "version": "npm run version:sync-internal && npm run release:prepare && git add -A", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9b18e8e5b..7cf98e142 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -8,6 +8,7 @@ "build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", "dev": "./scripts/dev.sh", + "dev:win": "powershell ./scripts/dev.ps1", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 new file mode 100644 index 000000000..609704eac --- /dev/null +++ b/packages/desktop/scripts/dev.ps1 @@ -0,0 +1,36 @@ +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$DesktopDir = (Resolve-Path "$ScriptDir\..").Path +$AppDir = (Resolve-Path "$DesktopDir\..\app").Path +$RootDir = (Resolve-Path "$DesktopDir\..\..").Path + +# Build the Electron main process +npm run build:main + +# Get a random available port for Metro +$env:EXPO_PORT = (npx get-port-cli).Trim() + +# Set EXPO_DEV_URL in the environment so Electron inherits it +$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)" + +# Allow any origin in dev so Electron on random ports works. +# SECURITY: wildcard CORS is unsafe in production — only acceptable here because +# the daemon binds to localhost and this script is never used for production. +$env:PASEO_CORS_ORIGINS = "*" + +Write-Host @" +====================================================== + Paseo Desktop Dev (Windows) +====================================================== + Metro: http://localhost:$($env:EXPO_PORT) +====================================================== +"@ + +# Launch Metro + Electron together, kill both on exit +& "$RootDir\node_modules\.bin\concurrently" ` + --kill-others ` + --names "metro,electron" ` + --prefix-colors "magenta,cyan" ` + "cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" ` + "npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`"" diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 000000000..d86811c82 --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,65 @@ +$ErrorActionPreference = "Stop" + +# Ensure node_modules/.bin is in PATH +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$env:PATH = "$ScriptDir\..\node_modules\.bin;$env:PATH" + +# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise +if (-not $env:PASEO_HOME) { + $GitDir = git rev-parse --git-dir 2>$null + $GitCommonDir = git rev-parse --git-common-dir 2>$null + + if ($GitDir -and $GitCommonDir -and ($GitDir -ne $GitCommonDir)) { + # Inside a worktree — derive a stable home from the worktree name + $WorktreeRoot = git rev-parse --show-toplevel + $WorktreeName = (Split-Path -Leaf $WorktreeRoot).ToLower() -replace '[^a-z0-9-]', '-' -replace '-+', '-' -replace '^-|-$', '' + $env:PASEO_HOME = "$env:USERPROFILE\.paseo-$WorktreeName" + New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null + } else { + $env:PASEO_HOME = Join-Path ([System.IO.Path]::GetTempPath()) "paseo-dev-$([System.Guid]::NewGuid().ToString('N').Substring(0,6))" + New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null + # Register cleanup on exit + $TempPaseoHome = $env:PASEO_HOME + Register-EngineEvent PowerShell.Exiting -Action { + Remove-Item -Recurse -Force $TempPaseoHome -ErrorAction SilentlyContinue + } | Out-Null + } +} + +# Share speech models with the main install to avoid duplicate downloads +if (-not $env:PASEO_LOCAL_MODELS_DIR) { + $env:PASEO_LOCAL_MODELS_DIR = "$env:USERPROFILE\.paseo\models\local-speech" + New-Item -ItemType Directory -Force -Path $env:PASEO_LOCAL_MODELS_DIR | Out-Null +} + +Write-Host @" +====================================================== + Paseo Dev (Windows) +====================================================== + Home: $($env:PASEO_HOME) + Models: $($env:PASEO_LOCAL_MODELS_DIR) + Daemon: localhost:6767 +====================================================== +"@ + +# Allow any origin in dev so Electron on random ports all work. +# SECURITY: wildcard CORS is unsafe in production — only acceptable here because +# the daemon binds to localhost and this script is never used for production. +# Build dependencies required by the daemon (they only ship dist/) +Write-Host "Building @getpaseo/highlight..." +npm run build --workspace=@getpaseo/highlight +Write-Host "Building @getpaseo/relay..." +npm run build --workspace=@getpaseo/relay + +$env:PASEO_CORS_ORIGINS = "*" + +# Configure the app to auto-connect to this daemon on localhost +$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6767" +$env:BROWSER = "none" + +# Run both with concurrently +concurrently ` + --names "daemon,metro" ` + --prefix-colors "cyan,magenta" ` + "npm run dev:server" ` + "cd packages/app && npx expo start" From 3cdadd362ceb11c1a82ed4c9b65554856079ed22 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 18:07:58 +0700 Subject: [PATCH 05/33] chore: remove noisy WorkspaceFetch debug logs --- packages/app/src/contexts/session-context.tsx | 28 ------------------- .../src/hooks/use-sidebar-workspaces-list.ts | 26 ----------------- 2 files changed, 54 deletions(-) diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 2a10864bb..6928c14a8 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -53,7 +53,6 @@ import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { isNative } from "@/constants/platform"; -import { summarizeWorkspaceCollection } from "@/utils/workspace-fetch-debug"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -334,24 +333,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider let includeSubscribe = options?.subscribe ?? false; while (true) { - console.log("[WorkspaceFetch][hydrate] request", { - serverId, - cursor, - includeSubscribe, - existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()), - }); const payload = await client.fetchWorkspaces({ sort: [{ key: "activity_at", direction: "desc" }], ...(includeSubscribe ? { subscribe: {} } : {}), page: cursor ? { limit: 200, cursor } : { limit: 200 }, }); - console.log("[WorkspaceFetch][hydrate] response", { - serverId, - cursor, - includeSubscribe, - pageInfo: payload.pageInfo, - payload: summarizeWorkspaceCollection(payload.entries), - }); if (options?.isCancelled?.()) { return; } @@ -380,11 +366,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setWorkspaces(serverId, workspaces); setHasHydratedWorkspaces(serverId, true); - console.log("[WorkspaceFetch][hydrate] applied", { - serverId, - subscribed: options?.subscribe ?? false, - nextWorkspaces: summarizeWorkspaceCollection(workspaces.values()), - }); }, [client, isConnected, serverId, setHasHydratedWorkspaces, setWorkspaces], ); @@ -1172,10 +1153,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; if (message.payload.kind === "remove") { - console.log("[WorkspaceFetch][update] remove", { - serverId, - workspaceId: message.payload.id, - }); removeWorkspace(serverId, message.payload.id); return; } @@ -1183,11 +1160,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const existingWorkspace = useSessionStore .getState() .sessions[serverId]?.workspaces.get(workspace.id); - console.log("[WorkspaceFetch][update] upsert", { - serverId, - existedInStore: Boolean(existingWorkspace), - workspace: summarizeWorkspaceCollection([workspace]), - }); mergeWorkspaces(serverId, [workspace]); }); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 889b15297..e0f0d1896 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -9,10 +9,6 @@ import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; -import { - summarizeSidebarProjects, - summarizeWorkspaceCollection, -} from "@/utils/workspace-fetch-debug"; const EMPTY_ORDER: string[] = []; const EMPTY_PROJECTS: SidebarProjectEntry[] = []; @@ -293,13 +289,6 @@ export function useSidebarWorkspacesList(options?: { return; } - console.log("[WorkspaceFetch][sidebar] model", { - serverId, - connectionStatus, - hasHydratedWorkspaces, - sessionWorkspaces: summarizeWorkspaceCollection(sessionWorkspaces?.values()), - projects: summarizeSidebarProjects(projects), - }); }, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]); useEffect(() => { @@ -345,21 +334,10 @@ export function useSidebarWorkspacesList(options?: { let cursor: string | null = null; try { while (true) { - console.log("[WorkspaceFetch][sidebar-refresh] request", { - serverId, - cursor, - existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()), - }); const payload = await client.fetchWorkspaces({ sort: [{ key: "activity_at", direction: "desc" }], page: cursor ? { limit: 200, cursor } : { limit: 200 }, }); - console.log("[WorkspaceFetch][sidebar-refresh] response", { - serverId, - cursor, - pageInfo: payload.pageInfo, - payload: summarizeWorkspaceCollection(payload.entries), - }); for (const entry of payload.entries) { const workspace = toWorkspaceDescriptor(entry); next.set( @@ -378,10 +356,6 @@ export function useSidebarWorkspacesList(options?: { const store = useSessionStore.getState(); store.setWorkspaces(serverId, next); store.setHasHydratedWorkspaces(serverId, true); - console.log("[WorkspaceFetch][sidebar-refresh] applied", { - serverId, - nextWorkspaces: summarizeWorkspaceCollection(next.values()), - }); } catch (error) { console.error("[WorkspaceFetch][sidebar-refresh] failed", { serverId, From 345729c5881b1956404c6d1a1ab54ce3aa0a3450 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 19:05:22 +0700 Subject: [PATCH 06/33] refactor: eliminate getCheckoutStatusLite, use WorkspaceGitService Route all git checkout queries through WorkspaceGitService instead of shelling out to git on every call. This makes fetch_agents_request instant when warm (cached in-memory snapshots) and deduplicates concurrent cold-start refreshes via ensureWorkspaceTarget. - buildProjectPlacementForCwd now uses workspaceGitService.getSnapshot() - worktree-session call sites use WorkspaceGitService - Deleted getCheckoutStatusLite and its 4 return types from checkout-git - Fixed cold-start thundering herd: getSnapshot deduplicates via ensureWorkspaceTarget instead of spawning parallel git processes - Extracted checkout-diff logic into WorkspaceGitService --- CI_STATUS.md | 19 - .../src/hooks/use-sidebar-workspaces-list.ts | 1 - packages/server/src/server/bootstrap.ts | 8 + .../src/server/checkout-diff-manager.test.ts | 241 +++++++----- .../src/server/checkout-diff-manager.ts | 263 ++------------ packages/server/src/server/session.ts | 4 +- .../session.workspace-git-watch.test.ts | 62 +--- .../src/server/session.workspaces.test.ts | 45 ++- .../server/src/server/websocket-server.ts | 68 +++- .../src/server/workspace-git-service.test.ts | 228 ++++++++++++ .../src/server/workspace-git-service.ts | 342 +++++++++++++++++- .../workspace-registry-bootstrap.test.ts | 65 ++++ .../server/workspace-registry-bootstrap.ts | 4 +- .../src/server/workspace-registry-model.ts | 46 +-- .../server/src/server/worktree-session.ts | 34 +- .../server/src/utils/checkout-git.test.ts | 26 -- packages/server/src/utils/checkout-git.ts | 69 ---- packages/server/vitest.config.ts | 2 +- vitest.config.ts | 7 + 19 files changed, 967 insertions(+), 567 deletions(-) delete mode 100644 CI_STATUS.md create mode 100644 vitest.config.ts diff --git a/CI_STATUS.md b/CI_STATUS.md deleted file mode 100644 index 3c2798e24..000000000 --- a/CI_STATUS.md +++ /dev/null @@ -1,19 +0,0 @@ -# CI Test Status - -Tracking progress toward all-green CI. - -## CI Jobs - -| Job | Status | Notes | -|-----|--------|-------| -| format | unknown | `npx biome format .` | -| typecheck | unknown | `npm run typecheck` | -| server-tests | unknown | unit + integration (vitest) | -| app-tests | unknown | unit tests (vitest) | -| playwright | unknown | E2E tests (playwright) | -| relay-tests | unknown | unit tests (vitest) | -| cli-tests | unknown | local tests | - -## Log - -- 2026-04-10: Branch created, automated agents begin iterating diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index e0f0d1896..327675b3d 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -288,7 +288,6 @@ export function useSidebarWorkspacesList(options?: { if (!serverId) { return; } - }, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]); useEffect(() => { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a2a6b7223..6a5e9b476 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -107,6 +107,7 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore } from "./daemon-config-store.js"; +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; @@ -368,6 +369,10 @@ export async function createPaseoDaemon( }); const terminalManager = createTerminalManager(); + const workspaceGitService = new WorkspaceGitServiceImpl({ + logger, + paseoHome: config.paseoHome, + }); const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, @@ -381,6 +386,7 @@ export async function createPaseoDaemon( agentStorage, projectRegistry, workspaceRegistry, + workspaceGitService, logger, }); logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); @@ -389,6 +395,7 @@ export async function createPaseoDaemon( const checkoutDiffManager = new CheckoutDiffManager({ logger, paseoHome: config.paseoHome, + workspaceGitService, }); const loopService = new LoopService({ paseoHome: config.paseoHome, @@ -619,6 +626,7 @@ export async function createPaseoDaemon( loopService, scheduleService, checkoutDiffManager, + workspaceGitService, ); if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") { diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 356f8cf15..35d573e7b 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,137 +1,184 @@ -import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted( - () => { - const hoistedWatchCalls: Array<{ path: string; close: ReturnType }> = []; - return { - getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), - resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"), - readdirMock: vi.fn(async (directory: string) => { - if (directory === "/tmp/repo") { - return [ - { name: "packages", isDirectory: () => true }, - { name: ".git", isDirectory: () => true }, - { name: "README.md", isDirectory: () => false }, - ]; - } - if (directory === path.join("/tmp/repo", "packages")) { - return [ - { name: "server", isDirectory: () => true }, - { name: "app", isDirectory: () => true }, - ]; - } - if (directory === path.join("/tmp/repo", "packages", "server")) { - return [{ name: "src", isDirectory: () => true }]; - } - if (directory === path.join("/tmp/repo", "packages", "server", "src")) { - return [{ name: "server", isDirectory: () => true }]; - } - return []; - }), - watchCalls: hoistedWatchCalls, - }; - }, -); - -vi.mock("../utils/run-git-command.js", () => ({ - runGitCommand: vi.fn(async () => ({ - stdout: "/tmp/repo\n", - stderr: "", - truncated: false, - exitCode: 0, - signal: null, +const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({ + getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), + toCheckoutErrorMock: vi.fn((error: unknown) => ({ + message: error instanceof Error ? error.message : String(error), })), })); -vi.mock("node:fs/promises", async () => { - const actual = await vi.importActual("node:fs/promises"); - return { - ...actual, - readdir: readdirMock, - }; -}); - -vi.mock("node:fs", async () => { - const actual = await vi.importActual("node:fs"); - return { - ...actual, - watch: vi.fn((watchPath: string) => { - const close = vi.fn(); - watchCalls.push({ path: watchPath, close }); - return { - close, - on: vi.fn().mockReturnThis(), - } as any; - }), - }; -}); - vi.mock("../utils/checkout-git.js", () => ({ getCheckoutDiff: getCheckoutDiffMock, })); vi.mock("./checkout-git-utils.js", () => ({ - READ_ONLY_GIT_ENV: {}, - resolveCheckoutGitDir: resolveCheckoutGitDirMock, - toCheckoutError: vi.fn((error: unknown) => ({ - message: error instanceof Error ? error.message : String(error), - })), + toCheckoutError: toCheckoutErrorMock, })); import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -describe("CheckoutDiffManager Linux watchers", () => { - const originalPlatform = process.platform; - +describe("CheckoutDiffManager", () => { beforeEach(() => { - watchCalls.length = 0; - getCheckoutDiffMock.mockClear(); - resolveCheckoutGitDirMock.mockClear(); - readdirMock.mockClear(); - Object.defineProperty(process, "platform", { - configurable: true, - value: "linux", - }); + vi.useFakeTimers(); + getCheckoutDiffMock.mockReset(); + getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] }); + toCheckoutErrorMock.mockClear(); }); afterEach(() => { - Object.defineProperty(process, "platform", { - configurable: true, - value: originalPlatform, - }); + vi.useRealTimers(); }); - test("watches nested repository directories on Linux", async () => { + function createManager(options?: { + repoRoot?: string | null; + getCheckoutDiffImplementation?: typeof getCheckoutDiffMock; + }) { + const unsubscribe = vi.fn(); + let onChange: (() => void) | null = null; + const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => { + onChange = listener; + return { + repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot, + unsubscribe, + }; + }); + + const workspaceGitService = { + subscribe: vi.fn(), + peekSnapshot: vi.fn(), + getSnapshot: vi.fn(), + refresh: vi.fn(), + scheduleRefreshForCwd: vi.fn(), + requestWorkingTreeWatch: mockRequestWorkingTreeWatch, + dispose: vi.fn(), + }; + + if (options?.getCheckoutDiffImplementation) { + getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation); + } + const logger = { child: () => logger, warn: vi.fn(), }; + const manager = new CheckoutDiffManager({ logger: logger as any, paseoHome: "/tmp/paseo-test", + workspaceGitService: workspaceGitService as any, }); - const subscription = await manager.subscribe( + return { + manager, + workspaceGitService, + mockRequestWorkingTreeWatch, + unsubscribe, + getOnChange: () => onChange, + }; + } + + test("subscribe requests a working tree watch with the correct cwd", async () => { + const { manager, mockRequestWorkingTreeWatch } = createManager(); + + await manager.subscribe( { - cwd: path.join("/tmp/repo", "packages", "server"), + cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" }, }, () => {}, ); - expect(subscription.initial.error).toBeNull(); - expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ - "/tmp/repo", - "/tmp/repo/.git", - "/tmp/repo/packages", - "/tmp/repo/packages/app", + expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith( "/tmp/repo/packages/server", - "/tmp/repo/packages/server/src", - "/tmp/repo/packages/server/src/server", - ]); + expect.any(Function), + ); + }); + + test("unsubscribe calls the working tree watch unsubscribe", async () => { + const { manager, unsubscribe } = createManager(); + + const subscription = await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); subscription.unsubscribe(); - manager.dispose(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + test("diffCwd uses repoRoot from the working tree watch result", async () => { + const { manager } = createManager({ repoRoot: "/tmp/repo" }); + + await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); + + expect(getCheckoutDiffMock).toHaveBeenCalledWith( + "/tmp/repo", + expect.objectContaining({ mode: "uncommitted", includeStructured: true }), + { paseoHome: "/tmp/paseo-test" }, + ); + }); + + test("diff refresh is triggered when the working tree watch callback fires", async () => { + getCheckoutDiffMock + .mockResolvedValueOnce({ + diff: "", + structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }], + }) + .mockResolvedValueOnce({ + diff: "", + structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], + }); + + const { manager, getOnChange } = createManager(); + const listener = vi.fn(); + + await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + listener, + ); + + const onChange = getOnChange(); + expect(onChange).toBeTypeOf("function"); + + onChange?.(); + await vi.advanceTimersByTimeAsync(150); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + cwd: "/tmp/repo/packages/server", + files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], + error: null, + }); + }); + + test("falls back to cwd when the working tree watch returns no repo root", async () => { + const { manager } = createManager({ repoRoot: null }); + + await manager.subscribe( + { + cwd: "/tmp/plain", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); + + expect(getCheckoutDiffMock).toHaveBeenCalledWith( + "/tmp/plain", + expect.objectContaining({ mode: "uncommitted", includeStructured: true }), + { paseoHome: "/tmp/paseo-test" }, + ); }); }); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 0274fff22..8e2029d96 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,15 +1,11 @@ -import { watch, type FSWatcher } from "node:fs"; -import { readdir } from "node:fs/promises"; -import { join } from "node:path"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { getCheckoutDiff } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; -import { runGitCommand } from "../utils/run-git-command.js"; -import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js"; +import { toCheckoutError } from "./checkout-git-utils.js"; const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; -const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000; export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; @@ -31,27 +27,26 @@ type CheckoutDiffWatchTarget = { diffCwd: string; compare: CheckoutDiffCompareInput; listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; - watchers: FSWatcher[]; - fallbackRefreshInterval: NodeJS.Timeout | null; + workingTreeWatchUnsubscribe: (() => void) | null; debounceTimer: NodeJS.Timeout | null; refreshPromise: Promise | null; refreshQueued: boolean; latestPayload: CheckoutDiffSnapshotPayload | null; latestFingerprint: string | null; - watchedPaths: Set; - repoWatchPath: string | null; - linuxTreeRefreshPromise: Promise | null; - linuxTreeRefreshQueued: boolean; }; export class CheckoutDiffManager { - private readonly logger: pino.Logger; private readonly paseoHome: string; + private readonly workspaceGitService: WorkspaceGitService; private readonly targets = new Map(); - constructor(options: { logger: pino.Logger; paseoHome: string }) { - this.logger = options.logger.child({ module: "checkout-diff-manager" }); + constructor(options: { + logger: pino.Logger; + paseoHome: string; + workspaceGitService: WorkspaceGitService; + }) { this.paseoHome = options.paseoHome; + this.workspaceGitService = options.workspaceGitService; } async subscribe( @@ -93,22 +88,16 @@ export class CheckoutDiffManager { getMetrics(): CheckoutDiffMetrics { let checkoutDiffSubscriptionCount = 0; - let checkoutDiffWatcherCount = 0; - let checkoutDiffFallbackRefreshTargetCount = 0; for (const target of this.targets.values()) { checkoutDiffSubscriptionCount += target.listeners.size; - checkoutDiffWatcherCount += target.watchers.length; - if (target.fallbackRefreshInterval) { - checkoutDiffFallbackRefreshTargetCount += 1; - } } return { checkoutDiffTargetCount: this.targets.size, checkoutDiffSubscriptionCount, - checkoutDiffWatcherCount, - checkoutDiffFallbackRefreshTargetCount, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, }; } @@ -144,15 +133,8 @@ export class CheckoutDiffManager { clearTimeout(target.debounceTimer); target.debounceTimer = null; } - if (target.fallbackRefreshInterval) { - clearInterval(target.fallbackRefreshInterval); - target.fallbackRefreshInterval = null; - } - for (const watcher of target.watchers) { - watcher.close(); - } - target.watchers = []; - target.watchedPaths.clear(); + target.workingTreeWatchUnsubscribe?.(); + target.workingTreeWatchUnsubscribe = null; target.listeners.clear(); } @@ -172,22 +154,6 @@ export class CheckoutDiffManager { this.targets.delete(targetKey); } - private async resolveCheckoutWatchRoot(cwd: string): Promise { - try { - const { stdout } = await runGitCommand( - ["rev-parse", "--path-format=absolute", "--show-toplevel"], - { - cwd, - env: READ_ONLY_GIT_ENV, - }, - ); - const root = stdout.trim(); - return root.length > 0 ? root : null; - } catch { - return null; - } - } - private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void { if (target.debounceTimer) { clearTimeout(target.debounceTimer); @@ -274,212 +240,27 @@ export class CheckoutDiffManager { return existing; } - const watchRoot = await this.resolveCheckoutWatchRoot(cwd); const target: CheckoutDiffWatchTarget = { key: targetKey, cwd, - diffCwd: watchRoot ?? cwd, + diffCwd: cwd, compare, listeners: new Set(), - watchers: [], - fallbackRefreshInterval: null, + workingTreeWatchUnsubscribe: null, debounceTimer: null, refreshPromise: null, refreshQueued: false, latestPayload: null, latestFingerprint: null, - watchedPaths: new Set(), - repoWatchPath: null, - linuxTreeRefreshPromise: null, - linuxTreeRefreshQueued: false, }; - - const repoWatchPath = watchRoot ?? cwd; - target.repoWatchPath = repoWatchPath; - const watchPaths = new Set([repoWatchPath]); - const gitDir = await resolveCheckoutGitDir(cwd); - if (gitDir) { - watchPaths.add(gitDir); - } - - let hasRecursiveRepoCoverage = false; - const allowRecursiveRepoWatch = process.platform !== "linux"; - if (process.platform === "linux") { - hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath); - } - for (const watchPath of watchPaths) { - if (process.platform === "linux" && watchPath === repoWatchPath) { - continue; - } - const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch; - const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive); - if (watchPath === repoWatchPath && watcherIsRecursive) { - hasRecursiveRepoCoverage = true; - } - } - - const missingRepoCoverage = !hasRecursiveRepoCoverage; - if (target.watchers.length === 0 || missingRepoCoverage) { - target.fallbackRefreshInterval = setInterval(() => { - this.scheduleTargetRefresh(target); - }, CHECKOUT_DIFF_FALLBACK_REFRESH_MS); - this.logger.warn( - { - cwd, - compare, - intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS, - reason: - target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage", - }, - "Checkout diff watchers unavailable; using timed refresh fallback", - ); - } + const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( + cwd, + () => this.scheduleTargetRefresh(target), + ); + target.diffCwd = repoRoot ?? cwd; + target.workingTreeWatchUnsubscribe = unsubscribe; this.targets.set(targetKey, target); return target; } - - private addWatcher( - target: CheckoutDiffWatchTarget, - watchPath: string, - shouldTryRecursive: boolean, - ): boolean { - if (target.watchedPaths.has(watchPath)) { - return false; - } - - const { cwd, compare } = target; - const onChange = () => { - if (process.platform === "linux" && target.repoWatchPath) { - void this.refreshLinuxRepoTreeWatchers(target); - } - this.scheduleTargetRefresh(target); - }; - const createWatcher = (recursive: boolean): FSWatcher => - watch(watchPath, { recursive }, () => { - onChange(); - }); - - let watcher: FSWatcher | null = null; - let watcherIsRecursive = false; - try { - if (shouldTryRecursive) { - watcher = createWatcher(true); - watcherIsRecursive = true; - } else { - watcher = createWatcher(false); - } - } catch (error) { - if (shouldTryRecursive) { - try { - watcher = createWatcher(false); - this.logger.warn( - { err: error, watchPath, cwd, compare }, - "Checkout diff recursive watch unavailable; using non-recursive fallback", - ); - } catch (fallbackError) { - this.logger.warn( - { err: fallbackError, watchPath, cwd, compare }, - "Failed to start checkout diff watcher", - ); - } - } else { - this.logger.warn( - { err: error, watchPath, cwd, compare }, - "Failed to start checkout diff watcher", - ); - } - } - - if (!watcher) { - return false; - } - - watcher.on("error", (error) => { - this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error"); - }); - target.watchers.push(watcher); - target.watchedPaths.add(watchPath); - return watcherIsRecursive; - } - - private async ensureLinuxRepoTreeWatchers( - target: CheckoutDiffWatchTarget, - rootPath: string, - ): Promise { - const directories = await this.listLinuxWatchDirectories(rootPath); - let complete = true; - for (const directory of directories) { - const watcherWasRecursive = this.addWatcher(target, directory, false); - if (!watcherWasRecursive && !target.watchedPaths.has(directory)) { - complete = false; - } - } - return complete && target.watchedPaths.has(rootPath); - } - - private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): Promise { - if (process.platform !== "linux" || !target.repoWatchPath) { - return; - } - const rootPath = target.repoWatchPath; - if (target.linuxTreeRefreshPromise) { - target.linuxTreeRefreshQueued = true; - return; - } - - target.linuxTreeRefreshPromise = (async () => { - do { - target.linuxTreeRefreshQueued = false; - try { - await this.ensureLinuxRepoTreeWatchers(target, rootPath); - } catch (error) { - this.logger.warn( - { - err: error, - cwd: target.cwd, - compare: target.compare, - rootPath, - }, - "Failed to refresh Linux checkout diff tree watchers", - ); - } - } while (target.linuxTreeRefreshQueued); - })(); - - try { - await target.linuxTreeRefreshPromise; - } finally { - target.linuxTreeRefreshPromise = null; - } - } - - private async listLinuxWatchDirectories(rootPath: string): Promise { - const directories: string[] = []; - const pending = [rootPath]; - - while (pending.length > 0) { - const directory = pending.pop(); - if (!directory) { - continue; - } - directories.push(directory); - - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === ".git") { - continue; - } - pending.push(join(directory, entry.name)); - } - } - - return directories; - } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8ef1fb197..2a80b0e2d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1404,7 +1404,7 @@ export class Session { private async buildProjectPlacement(cwd: string): Promise { return buildProjectPlacementForCwd({ cwd, - paseoHome: this.paseoHome, + workspaceGitService: this.workspaceGitService, }); } @@ -3209,6 +3209,7 @@ export class Session { { paseoHome: this.paseoHome, sessionLogger: this.sessionLogger, + workspaceGitService: this.workspaceGitService, checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch), createBranchFromBase: (params) => this.createBranchFromBase(params), }, @@ -6263,6 +6264,7 @@ export class Session { return handleCreateWorktreeRequest( { paseoHome: this.paseoHome, + workspaceGitService: this.workspaceGitService, describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace), emit: (message) => this.emit(message), registerPendingWorktreeWorkspace: (options) => diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 3e0ca570f..d893ca586 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -65,6 +65,8 @@ function createSessionForWorkspaceGitWatchTests(): { peekSnapshot: ReturnType; getSnapshot: ReturnType; refresh: ReturnType; + requestWorkingTreeWatch: ReturnType; + scheduleRefreshForCwd: ReturnType; dispose: ReturnType; }; subscriptions: Array<{ @@ -105,6 +107,11 @@ function createSessionForWorkspaceGitWatchTests(): { peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), refresh: vi.fn(async () => {}), + requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: vi.fn(), + })), + scheduleRefreshForCwd: vi.fn(), dispose: vi.fn(), }; @@ -328,62 +335,17 @@ describe("workspace git watch targets", () => { }); }); - test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => { + test("checkout_pr_status_request reads cached snapshot without forcing a refresh", async () => { const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); - let refreshed = false; - - workspaceGitService.refresh.mockImplementation(async () => { - refreshed = true; - }); - workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) => - createWorkspaceRuntimeSnapshot(cwd, { - github: { - pullRequest: refreshed - ? { - url: "https://github.com/acme/repo/pull/457", - title: "After explicit refresh", - state: "merged", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: true, - } - : { - url: "https://github.com/acme/repo/pull/456", - title: "Before explicit refresh", - state: "open", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: false, - }, - refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z", - }, - }), - ); await session.handleMessage({ type: "checkout_pr_status_request", cwd: "/tmp/repo", - requestId: "req-pr-refresh", + requestId: "req-pr-cached", }); - expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", { - priority: "high", - }); - expect( - emitted.find((message) => message.type === "checkout_pr_status_response")?.payload, - ).toEqual({ - cwd: "/tmp/repo", - status: { - url: "https://github.com/acme/repo/pull/457", - title: "After explicit refresh", - state: "merged", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: true, - }, - githubFeaturesEnabled: true, - error: null, - requestId: "req-pr-refresh", - }); + expect(workspaceGitService.refresh).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined(); }); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 464c66fb6..fe9062de5 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -113,6 +113,11 @@ function createNoopWorkspaceGitService() { }, }), refresh: async () => {}, + requestWorkingTreeWatch: async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, dispose: () => {}, }; } @@ -1132,7 +1137,6 @@ describe("workspace aggregation", () => { test("create paseo worktree request returns a registered workspace descriptor", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); const repoDir = path.join(tempDir, "repo"); const paseoHome = path.join(tempDir, "paseo-home"); @@ -1143,6 +1147,45 @@ describe("workspace aggregation", () => { writeFileSync(path.join(repoDir, "file.txt"), "hello\n"); execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => { + if (cwd === repoDir) { + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: repoDir, + currentBranch: "main", + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + } + + if (cwd.includes("worktree-123")) { + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: cwd, + currentBranch: "worktree-123", + remoteUrl: null, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, + }); + } + + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: cwd, + currentBranch: "main", + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + }); + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; const workspaces = new Map(); const projects = new Map(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 411c8dc7e..637a1902b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -66,6 +66,66 @@ type WebSocketServerConfig = { type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; +function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl { + return { + subscribe: async ({ cwd }: { cwd: string }) => ({ + initial: { + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }, + unsubscribe: () => {}, + }), + peekSnapshot: () => null, + getSnapshot: async (cwd: string) => ({ + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }), + refresh: async () => {}, + requestWorkingTreeWatch: async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + dispose: () => {}, + } as unknown as WorkspaceGitServiceImpl; +} + function createNoopProjectRegistry(): ProjectRegistry { return { initialize: async () => {}, @@ -307,6 +367,7 @@ export class VoiceAssistantWebSocketServer { loopService?: LoopService, scheduleService?: ScheduleService, checkoutDiffManager?: CheckoutDiffManager, + workspaceGitService?: WorkspaceGitServiceImpl, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -334,10 +395,7 @@ export class VoiceAssistantWebSocketServer { throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager."); } this.checkoutDiffManager = checkoutDiffManager; - this.workspaceGitService = new WorkspaceGitServiceImpl({ - logger: this.logger, - paseoHome, - }); + this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.daemonConfigStore = daemonConfigStore; @@ -515,8 +573,8 @@ export class VoiceAssistantWebSocketServer { await Promise.all(cleanupPromises); this.providerSnapshotManager.destroy(); - this.workspaceGitService.dispose(); this.checkoutDiffManager.dispose(); + this.workspaceGitService.dispose(); this.pendingConnections.clear(); this.sessions.clear(); this.externalSessionsByKey.clear(); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 97baf930c..8d9ecc886 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import path from "node:path"; import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js"; import { WorkspaceGitServiceImpl, @@ -116,11 +117,28 @@ function createWatcher() { }; } +function createDirent(name: string, isDirectory: boolean) { + return { + name, + isDirectory: () => isDirectory, + }; +} + async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function createService(options?: { getCheckoutStatus?: ReturnType; getCheckoutShortstat?: ReturnType; @@ -129,6 +147,8 @@ function createService(options?: { resolveAbsoluteGitDir?: ReturnType; hasOriginRemote?: ReturnType; runGitFetch?: ReturnType; + runGitCommand?: ReturnType; + readdir?: ReturnType; watch?: ReturnType; now?: () => Date; }) { @@ -137,6 +157,7 @@ function createService(options?: { paseoHome: "/tmp/paseo-test", deps: { watch: options?.watch ?? ((() => createWatcher()) as unknown as any), + readdir: options?.readdir ?? vi.fn(async () => []), getCheckoutStatus: options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), getCheckoutShortstat: @@ -151,6 +172,15 @@ function createService(options?: { resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"), hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false), runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}), + runGitCommand: + options?.runGitCommand ?? + vi.fn(async () => ({ + stdout: "/tmp/repo\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")), }, }); @@ -217,6 +247,48 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => { + const checkoutStatusDeferred = createDeferred(); + const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + resolveAbsoluteGitDir, + }); + + const firstSnapshotPromise = service.getSnapshot("/tmp/repo"); + const secondSnapshotPromise = service.getSnapshot("/tmp/repo/."); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(0); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); + expect((service as any).workspaceTargets.size).toBe(0); + expect((service as any).workspaceTargetSetups.size).toBe(1); + + checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); + + await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([ + createSnapshot("/tmp/repo"), + createSnapshot("/tmp/repo"), + ]); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect((service as any).workspaceTargets.size).toBe(1); + expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); @@ -432,4 +504,160 @@ describe("WorkspaceGitServiceImpl", () => { subscription.unsubscribe(); service.dispose(); }); + + test("watches nested repository directories on Linux", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + configurable: true, + value: "linux", + }); + + const watchCalls: Array<{ path: string; close: ReturnType }> = []; + const watch = vi.fn((watchPath: string) => { + const watcher = createWatcher(); + watchCalls.push({ path: watchPath, close: watcher.close }); + return watcher as any; + }); + const readdir = vi.fn(async (directory: string) => { + if (directory === "/tmp/repo") { + return [ + createDirent("packages", true), + createDirent(".git", true), + createDirent("README.md", false), + ]; + } + if (directory === path.join("/tmp/repo", "packages")) { + return [createDirent("server", true), createDirent("app", true)]; + } + if (directory === path.join("/tmp/repo", "packages", "server")) { + return [createDirent("src", true)]; + } + if (directory === path.join("/tmp/repo", "packages", "server", "src")) { + return [createDirent("server", true)]; + } + return []; + }); + + const service = createService({ watch, readdir }); + const subscription = await service.requestWorkingTreeWatch( + path.join("/tmp/repo", "packages", "server"), + vi.fn(), + ); + + expect(subscription.repoRoot).toBe("/tmp/repo"); + expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ + "/tmp/repo", + "/tmp/repo/.git", + "/tmp/repo/packages", + "/tmp/repo/packages/app", + "/tmp/repo/packages/server", + "/tmp/repo/packages/server/src", + "/tmp/repo/packages/server/src/server", + ]); + + subscription.unsubscribe(); + service.dispose(); + Object.defineProperty(process, "platform", { + configurable: true, + value: originalPlatform, + }); + }); + + test("requestWorkingTreeWatch reference-counts watchers by cwd", async () => { + const watchers = [createWatcher(), createWatcher()]; + const watch = vi + .fn() + .mockReturnValueOnce(watchers[0] as any) + .mockReturnValueOnce(watchers[1] as any); + const service = createService({ watch }); + + const firstListener = vi.fn(); + const secondListener = vi.fn(); + const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener); + const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener); + + expect(first.repoRoot).toBe("/tmp/repo"); + expect(second.repoRoot).toBe("/tmp/repo"); + expect(watch).toHaveBeenCalledTimes(2); + + first.unsubscribe(); + expect(watchers[0].close).not.toHaveBeenCalled(); + expect(watchers[1].close).not.toHaveBeenCalled(); + + second.unsubscribe(); + expect(watchers[0].close).toHaveBeenCalledTimes(1); + expect(watchers[1].close).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => { + const recursiveUnsupported = new Error("recursive unsupported"); + const watch = vi + .fn() + .mockImplementationOnce((_watchPath: string, options: { recursive: boolean }) => { + if (options.recursive) { + throw recursiveUnsupported; + } + return createWatcher() as any; + }) + .mockImplementationOnce(() => createWatcher() as any); + + const service = createService({ watch }); + const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn()); + const target = (service as any).workingTreeWatchTargets.get("/tmp/repo"); + + expect(target?.fallbackRefreshInterval).not.toBeNull(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("non-git directories fall back to watching cwd with polling", async () => { + const watch = vi.fn(() => createWatcher() as any); + const runGitCommand = vi.fn(async () => { + throw new Error("not a git repository"); + }); + const resolveAbsoluteGitDir = vi.fn(async () => null); + const service = createService({ + watch, + runGitCommand, + resolveAbsoluteGitDir, + }); + + const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn()); + const target = (service as any).workingTreeWatchTargets.get("/tmp/plain"); + + expect(subscription.repoRoot).toBeNull(); + expect(watch).toHaveBeenCalledWith("/tmp/plain", { recursive: true }, expect.any(Function)); + expect(target?.repoWatchPath).toBe("/tmp/plain"); + expect(target?.fallbackRefreshInterval).not.toBeNull(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("working tree changes notify listeners and schedule workspace refresh", async () => { + const watchCallbacks: Array<() => void> = []; + const watch = vi.fn( + (_watchPath: string, _options: { recursive: boolean }, callback: () => void) => { + watchCallbacks.push(callback); + return createWatcher() as any; + }, + ); + const service = createService({ watch }); + const refreshSpy = vi.spyOn(service as any, "scheduleWorkspaceRefresh"); + const listener = vi.fn(); + + const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener); + expect(watchCallbacks).toHaveLength(2); + + watchCallbacks[0]?.(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo"); + + subscription.unsubscribe(); + service.dispose(); + }); }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 62508470a..2adf7d315 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,5 +1,5 @@ import { watch, type FSWatcher } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { join, resolve } from "node:path"; import type pino from "pino"; import type { CheckoutContext } from "../utils/checkout-git.js"; @@ -12,10 +12,12 @@ import { resolveAbsoluteGitDir, } from "../utils/checkout-git.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; +const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; export type WorkspaceGitRuntimeSnapshot = { cwd: string; @@ -59,6 +61,11 @@ export interface WorkspaceGitService { peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; getSnapshot(cwd: string): Promise; refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise; + requestWorkingTreeWatch( + cwd: string, + onChange: () => void, + ): Promise<{ repoRoot: string | null; unsubscribe: () => void }>; + scheduleRefreshForCwd(cwd: string): void; dispose(): void; } @@ -66,6 +73,7 @@ export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => vo interface WorkspaceGitServiceDependencies { watch: typeof watch; + readdir: typeof readdir; getCheckoutStatus: typeof getCheckoutStatus; getCheckoutShortstat: typeof getCheckoutShortstat; getPullRequestStatus: typeof getPullRequestStatus; @@ -73,6 +81,7 @@ interface WorkspaceGitServiceDependencies { resolveAbsoluteGitDir: (cwd: string) => Promise; hasOriginRemote: (cwd: string) => Promise; runGitFetch: (cwd: string) => Promise; + runGitCommand: typeof runGitCommand; now: () => Date; } @@ -102,6 +111,18 @@ interface RepoGitTarget { fetchInFlight: boolean; } +interface WorkingTreeWatchTarget { + cwd: string; + repoRoot: string | null; + repoWatchPath: string | null; + watchers: FSWatcher[]; + watchedPaths: Set; + fallbackRefreshInterval: NodeJS.Timeout | null; + linuxTreeRefreshPromise: Promise | null; + linuxTreeRefreshQueued: boolean; + listeners: Set<() => void>; +} + export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; @@ -109,12 +130,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); private readonly workspaceTargetSetups = new Map>(); + private readonly workingTreeWatchTargets = new Map(); + private readonly workingTreeWatchSetups = new Map>(); constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; this.deps = { - watch, + watch: options.deps?.watch ?? watch, + readdir: options.deps?.readdir ?? readdir, getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus, getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat, getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus, @@ -122,6 +146,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir, hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote, runGitFetch: options.deps?.runGitFetch ?? runGitFetch, + runGitCommand: options.deps?.runGitCommand ?? runGitCommand, now: options.deps?.now ?? (() => new Date()), }; } @@ -151,7 +176,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (target?.latestSnapshot) { return target.latestSnapshot; } - return this.refreshSnapshot(cwd); + + const ensuredTarget = await this.ensureWorkspaceTarget(cwd); + return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd)); } peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { @@ -170,17 +197,47 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { await this.ensureWorkspaceTarget(cwd); } + async requestWorkingTreeWatch( + cwd: string, + onChange: () => void, + ): Promise<{ repoRoot: string | null; unsubscribe: () => void }> { + cwd = normalizeWorkspaceId(cwd); + const target = await this.ensureWorkingTreeWatchTarget(cwd); + target.listeners.add(onChange); + + return { + repoRoot: target.repoRoot, + unsubscribe: () => { + this.removeWorkingTreeWatchListener(cwd, onChange); + }, + }; + } + + scheduleRefreshForCwd(cwd: string): void { + cwd = normalizeWorkspaceId(cwd); + const target = this.workspaceTargets.get(cwd); + if (target) { + this.scheduleWorkspaceRefresh(target); + } + } + dispose(): void { for (const target of this.workspaceTargets.values()) { this.closeWorkspaceTarget(target); } this.workspaceTargets.clear(); + this.workspaceTargetSetups.clear(); for (const target of this.repoTargets.values()) { this.closeRepoTarget(target); } this.repoTargets.clear(); - this.workspaceTargetSetups.clear(); + + for (const target of this.workingTreeWatchTargets.values()) { + this.closeWorkingTreeWatchTarget(target); + } + this.workingTreeWatchTargets.clear(); + this.workingTreeWatchSetups.clear(); } private async ensureWorkspaceTarget(cwd: string): Promise { @@ -201,6 +258,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return setup; } + private async ensureWorkingTreeWatchTarget(cwd: string): Promise { + const existingTarget = this.workingTreeWatchTargets.get(cwd); + if (existingTarget) { + return existingTarget; + } + + const existingSetup = this.workingTreeWatchSetups.get(cwd); + if (existingSetup) { + return existingSetup; + } + + const setup = this.createWorkingTreeWatchTarget(cwd).finally(() => { + this.workingTreeWatchSetups.delete(cwd); + }); + this.workingTreeWatchSetups.set(cwd, setup); + return setup; + } + private async createWorkspaceTarget(cwd: string): Promise { const target: WorkspaceGitTarget = { cwd, @@ -230,6 +305,83 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target; } + private async createWorkingTreeWatchTarget(cwd: string): Promise { + const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + const target: WorkingTreeWatchTarget = { + cwd, + repoRoot, + repoWatchPath: null, + watchers: [], + watchedPaths: new Set(), + fallbackRefreshInterval: null, + linuxTreeRefreshPromise: null, + linuxTreeRefreshQueued: false, + listeners: new Set(), + }; + + const repoWatchPath = repoRoot ?? cwd; + target.repoWatchPath = repoWatchPath; + const watchPaths = new Set([repoWatchPath]); + const gitDir = await this.deps.resolveAbsoluteGitDir(cwd); + if (gitDir) { + watchPaths.add(gitDir); + } + + let hasRecursiveRepoCoverage = false; + const allowRecursiveRepoWatch = process.platform !== "linux"; + if (process.platform === "linux") { + hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath); + } + for (const watchPath of watchPaths) { + if (process.platform === "linux" && watchPath === repoWatchPath) { + continue; + } + const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch; + const watcherIsRecursive = this.addWorkingTreeWatcher(target, watchPath, shouldTryRecursive); + if (watchPath === repoWatchPath && watcherIsRecursive) { + hasRecursiveRepoCoverage = true; + } + } + + const missingRepoCoverage = repoRoot === null || !hasRecursiveRepoCoverage; + if (target.watchers.length === 0 || missingRepoCoverage) { + target.fallbackRefreshInterval = setInterval(() => { + this.scheduleWorkspaceRefresh(cwd); + for (const listener of target.listeners) { + listener(); + } + }, WORKING_TREE_WATCH_FALLBACK_REFRESH_MS); + this.logger.warn( + { + cwd, + intervalMs: WORKING_TREE_WATCH_FALLBACK_REFRESH_MS, + reason: + target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage", + }, + "Working tree watchers unavailable; using timed refresh fallback", + ); + } + + this.workingTreeWatchTargets.set(cwd, target); + return target; + } + + private async resolveCheckoutWatchRoot(cwd: string): Promise { + try { + const { stdout } = await this.deps.runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); + const root = stdout.trim(); + return root.length > 0 ? root : null; + } catch { + return null; + } + } + private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise { try { const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); @@ -308,7 +460,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { void this.runRepoFetch(repoTarget); } - private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void { + private scheduleWorkspaceRefresh(targetOrCwd: WorkspaceGitTarget | string): void { + const target = + typeof targetOrCwd === "string" + ? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd)) + : targetOrCwd; + if (!target) { + return; + } + if (target.debounceTimer) { clearTimeout(target.debounceTimer); } @@ -319,6 +479,149 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); } + private addWorkingTreeWatcher( + target: WorkingTreeWatchTarget, + watchPath: string, + shouldTryRecursive: boolean, + ): boolean { + if (target.watchedPaths.has(watchPath)) { + return false; + } + + const { cwd } = target; + const onChange = () => { + if (process.platform === "linux" && target.repoWatchPath) { + void this.refreshLinuxRepoTreeWatchers(target); + } + this.scheduleWorkspaceRefresh(cwd); + for (const listener of target.listeners) { + listener(); + } + }; + const createWatcher = (recursive: boolean): FSWatcher => + this.deps.watch(watchPath, { recursive }, () => { + onChange(); + }); + + let watcher: FSWatcher | null = null; + let watcherIsRecursive = false; + try { + if (shouldTryRecursive) { + watcher = createWatcher(true); + watcherIsRecursive = true; + } else { + watcher = createWatcher(false); + } + } catch (error) { + if (shouldTryRecursive) { + try { + watcher = createWatcher(false); + this.logger.warn( + { err: error, watchPath, cwd }, + "Working tree recursive watch unavailable; using non-recursive fallback", + ); + } catch (fallbackError) { + this.logger.warn( + { err: fallbackError, watchPath, cwd }, + "Failed to start working tree watcher", + ); + } + } else { + this.logger.warn({ err: error, watchPath, cwd }, "Failed to start working tree watcher"); + } + } + + if (!watcher) { + return false; + } + + watcher.on("error", (error) => { + this.logger.warn({ err: error, watchPath, cwd }, "Working tree watcher error"); + }); + target.watchers.push(watcher); + target.watchedPaths.add(watchPath); + return watcherIsRecursive; + } + + private async ensureLinuxRepoTreeWatchers( + target: WorkingTreeWatchTarget, + rootPath: string, + ): Promise { + const directories = await this.listLinuxWatchDirectories(rootPath); + let complete = true; + for (const directory of directories) { + const watcherWasRecursive = this.addWorkingTreeWatcher(target, directory, false); + if (!watcherWasRecursive && !target.watchedPaths.has(directory)) { + complete = false; + } + } + return complete && target.watchedPaths.has(rootPath); + } + + private async refreshLinuxRepoTreeWatchers(target: WorkingTreeWatchTarget): Promise { + if (process.platform !== "linux" || !target.repoWatchPath) { + return; + } + const rootPath = target.repoWatchPath; + if (target.linuxTreeRefreshPromise) { + target.linuxTreeRefreshQueued = true; + return; + } + + target.linuxTreeRefreshPromise = (async () => { + do { + target.linuxTreeRefreshQueued = false; + try { + await this.ensureLinuxRepoTreeWatchers(target, rootPath); + } catch (error) { + this.logger.warn( + { + err: error, + cwd: target.cwd, + rootPath, + }, + "Failed to refresh Linux working tree watchers", + ); + } + } while (target.linuxTreeRefreshQueued); + })(); + + try { + await target.linuxTreeRefreshPromise; + } finally { + target.linuxTreeRefreshPromise = null; + } + } + + private async listLinuxWatchDirectories(rootPath: string): Promise { + const directories: string[] = []; + const pending = [rootPath]; + + while (pending.length > 0) { + const directory = pending.pop(); + if (!directory) { + continue; + } + directories.push(directory); + + let entries; + try { + entries = await this.deps.readdir(directory, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === ".git") { + continue; + } + pending.push(join(directory, entry.name)); + } + } + + return directories; + } + private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise { if (target.refreshPromise) { target.refreshQueued = true; @@ -435,6 +738,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.workspaceTargets.delete(target.cwd); } + private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void { + const target = this.workingTreeWatchTargets.get(cwd); + if (!target) { + return; + } + + target.listeners.delete(listener); + if (target.listeners.size > 0) { + return; + } + + this.closeWorkingTreeWatchTarget(target); + this.workingTreeWatchTargets.delete(cwd); + } + private closeWorkspaceTarget(target: WorkspaceGitTarget): void { if (target.debounceTimer) { clearTimeout(target.debounceTimer); @@ -448,6 +766,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.listeners.clear(); } + private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void { + if (target.fallbackRefreshInterval) { + clearInterval(target.fallbackRefreshInterval); + target.fallbackRefreshInterval = null; + } + + for (const watcher of target.watchers) { + watcher.close(); + } + target.watchers = []; + target.watchedPaths.clear(); + target.listeners.clear(); + } + private closeRepoTarget(target: RepoGitTarget): void { if (target.intervalId) { clearInterval(target.intervalId); diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 66fa5d442..5f81aa3e2 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -6,15 +6,77 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; import { AgentStorage } from "./agent/agent-storage.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +function createNoopWorkspaceGitService(): WorkspaceGitService { + return { + subscribe: async (params) => ({ + initial: { + cwd: params.cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }, + unsubscribe: () => {}, + }), + peekSnapshot: () => null, + getSnapshot: async (cwd) => ({ + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }), + refresh: async () => {}, + requestWorkingTreeWatch: async () => ({ + repoRoot: null, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + dispose: () => {}, + }; +} + describe("bootstrapWorkspaceRegistries", () => { let tmpDir: string; let paseoHome: string; let agentStorage: AgentStorage; let projectRegistry: FileBackedProjectRegistry; let workspaceRegistry: FileBackedWorkspaceRegistry; + let workspaceGitService: WorkspaceGitService; const logger = createTestLogger(); beforeEach(() => { @@ -29,6 +91,7 @@ describe("bootstrapWorkspaceRegistries", () => { path.join(paseoHome, "projects", "workspaces.json"), logger, ); + workspaceGitService = createNoopWorkspaceGitService(); }); afterEach(() => { @@ -94,6 +157,7 @@ describe("bootstrapWorkspaceRegistries", () => { agentStorage, projectRegistry, workspaceRegistry, + workspaceGitService, logger, }); @@ -157,6 +221,7 @@ describe("bootstrapWorkspaceRegistries", () => { agentStorage, projectRegistry, workspaceRegistry, + workspaceGitService, logger, }); diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index c9403f810..625334ffe 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -13,6 +13,7 @@ import { deriveWorkspaceKind, normalizeWorkspaceId, } from "./workspace-registry-model.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -53,6 +54,7 @@ export async function bootstrapWorkspaceRegistries(options: { agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceGitService: WorkspaceGitService; logger: Logger; }): Promise { const [projectsExists, workspacesExists] = await Promise.all([ @@ -79,7 +81,7 @@ export async function bootstrapWorkspaceRegistries(options: { const normalizedCwd = normalizeWorkspaceId(record.cwd); const placement = await buildProjectPlacementForCwd({ cwd: normalizedCwd, - paseoHome: options.paseoHome, + workspaceGitService: options.workspaceGitService, }); const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] }; diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 545095e71..8bce33279 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; -import { getCheckoutStatusLite } from "../utils/checkout-git.js"; import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; @@ -198,45 +198,15 @@ export async function detectStaleWorkspaces( export async function buildProjectPlacementForCwd(input: { cwd: string; - paseoHome: string; + workspaceGitService: WorkspaceGitService; }): Promise { const normalizedCwd = normalizeWorkspaceId(input.cwd); - const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome }) - .then((status): ProjectCheckoutLitePayload => { - if (!status.isGit) { - return { - cwd: normalizedCwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - } - - if (status.isPaseoOwnedWorktree && status.mainRepoRoot) { - return { - cwd: normalizedCwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - worktreeRoot: status.worktreeRoot, - isPaseoOwnedWorktree: true, - mainRepoRoot: status.mainRepoRoot, - }; - } - - return { - cwd: normalizedCwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - worktreeRoot: status.worktreeRoot, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - }) + const checkout = await input.workspaceGitService + .getSnapshot(normalizedCwd) + .then( + (snapshot): ProjectCheckoutLitePayload => + checkoutLiteFromGitSnapshot(normalizedCwd, snapshot.git), + ) .catch( (): ProjectCheckoutLitePayload => ({ cwd: normalizedCwd, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 3ce9af075..2b343e6c3 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -17,10 +17,11 @@ import type { ProjectRegistry, WorkspaceRegistry, } from "./workspace-registry.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; import { createAgentWorktree } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; +import { resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { computeWorktreePath, @@ -51,6 +52,7 @@ type EmitSessionMessage = (message: SessionOutboundMessage) => void; type BuildAgentSessionConfigDependencies = { paseoHome?: string; sessionLogger: Logger; + workspaceGitService: WorkspaceGitService; checkoutExistingBranch: (cwd: string, branch: string) => Promise; createBranchFromBase: (params: { cwd: string; @@ -99,6 +101,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = { type HandleCreatePaseoWorktreeRequestDependencies = { paseoHome?: string; + workspaceGitService: WorkspaceGitService; describeWorkspaceRecord: ( workspace: PersistedWorkspaceRecord, ) => Promise; @@ -167,7 +170,8 @@ export async function buildAgentSessionConfig( ); const baseBranch = - normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + normalized.baseBranch ?? + (await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService)); const createdWorktree = await createAgentWorktree({ branchName: targetBranch, cwd, @@ -179,7 +183,8 @@ export async function buildAgentSessionConfig( worktreeConfig = createdWorktree; } else if (normalized.createNewBranch) { const baseBranch = - normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + normalized.baseBranch ?? + (await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService)); await dependencies.createBranchFromBase({ cwd, baseBranch, @@ -264,13 +269,18 @@ export function assertSafeGitRef(ref: string, label: string): void { } } -export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise { - const checkout = await getCheckoutStatusLite(cwd, { paseoHome }); - if (!checkout.isGit) { +export async function resolveGitCreateBaseBranch( + cwd: string, + workspaceGitService: WorkspaceGitService, +): Promise { + const snapshot = await workspaceGitService.getSnapshot(cwd); + if (!snapshot.git.isGit) { throw new Error("Cannot create a worktree outside a git repository"); } - const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd; + const repoRoot = snapshot.git.isPaseoOwnedWorktree + ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd) + : (snapshot.git.repoRoot ?? cwd); const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); if (!baseBranch) { throw new Error("Unable to resolve repository default branch"); @@ -545,14 +555,14 @@ export async function handleCreatePaseoWorktreeRequest( request: Extract, ): Promise { try { - const checkout = await getCheckoutStatusLite(request.cwd, { - paseoHome: dependencies.paseoHome, - }); - if (!checkout.isGit) { + const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd); + if (!snapshot.git.isGit) { throw new Error("Create worktree requires a git repository"); } - const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd; + const repoRoot = snapshot.git.isPaseoOwnedWorktree + ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd) + : (snapshot.git.repoRoot ?? request.cwd); const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); if (!baseBranch) { throw new Error("Unable to resolve repository default branch"); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 9a42ad928..3ce01f7cc 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -22,7 +22,6 @@ import { getCheckoutShortstat, getPullRequestStatus, getCheckoutStatus, - getCheckoutStatusLite, listBranchSuggestions, mergeToBase, mergeFromBase, @@ -164,15 +163,6 @@ const x = 1; expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]); }); - it("returns lightweight checkout status for normal repos", async () => { - const status = await getCheckoutStatusLite(repoDir); - expect(status.isGit).toBe(true); - expect(status.currentBranch).toBe("main"); - expect(status.worktreeRoot).toBe(repoDir); - expect(status.isPaseoOwnedWorktree).toBe(false); - expect(status.mainRepoRoot).toBeNull(); - }); - it("exposes hasRemote when origin is configured", async () => { const remoteDir = join(tempDir, "remote.git"); execSync(`git init --bare -b main ${remoteDir}`); @@ -380,22 +370,6 @@ const x = 1; expect(message).toBe("worktree update"); }); - it("returns lightweight checkout status for .paseo worktrees", async () => { - const result = await createWorktree({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "lite-alpha", - paseoHome, - }); - - const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome }); - expect(status.isGit).toBe(true); - expect(status.worktreeRoot).toBe(result.worktreePath); - expect(status.isPaseoOwnedWorktree).toBe(true); - expect(status.mainRepoRoot).toBe(repoDir); - }); - it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => { const bareRepoDir = join(tempDir, "bare-repo"); execSync(`git clone --bare ${repoDir} ${bareRepoDir}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 45cd97f35..19669e8bc 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -482,38 +482,6 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit; -export type CheckoutStatusLiteNotGit = { - isGit: false; - currentBranch: null; - remoteUrl: null; - worktreeRoot: null; - isPaseoOwnedWorktree: false; - mainRepoRoot: null; -}; - -export type CheckoutStatusLiteGitNonPaseo = { - isGit: true; - currentBranch: string | null; - remoteUrl: string | null; - worktreeRoot: string; - isPaseoOwnedWorktree: false; - mainRepoRoot: null; -}; - -export type CheckoutStatusLiteGitPaseo = { - isGit: true; - currentBranch: string | null; - remoteUrl: string | null; - worktreeRoot: string; - isPaseoOwnedWorktree: true; - mainRepoRoot: string; -}; - -export type CheckoutStatusLiteResult = - | CheckoutStatusLiteNotGit - | CheckoutStatusLiteGitNonPaseo - | CheckoutStatusLiteGitPaseo; - export interface CheckoutDiffResult { diff: string; structured?: ParsedDiffFile[]; @@ -1156,43 +1124,6 @@ export async function getCheckoutStatus( }; } -export async function getCheckoutStatusLite( - cwd: string, - context?: CheckoutContext, -): Promise { - const inspected = await inspectCheckoutContext(cwd, context); - if (!inspected) { - return { - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - } - - if (inspected.configured.isPaseoOwnedWorktree) { - return { - isGit: true, - currentBranch: inspected.currentBranch, - remoteUrl: inspected.remoteUrl, - worktreeRoot: inspected.worktreeRoot, - isPaseoOwnedWorktree: true, - mainRepoRoot: await getMainRepoRoot(cwd), - }; - } - - return { - isGit: true, - currentBranch: inspected.currentBranch, - remoteUrl: inspected.remoteUrl, - worktreeRoot: inspected.worktreeRoot, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; -} - export interface CheckoutShortstat { additions: number; deletions: number; diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index a332dbac5..c4bf466d5 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -21,6 +21,6 @@ export default defineConfig({ maxForks: 1, }, }, - exclude: ["**/node_modules/**", "**/dist/**"], + exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"], }, }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..c39f1daeb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/.claude/**"], + }, +}); From 380c9927d704d00df9bae714f52cf74af5e37d78 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 19:12:13 +0700 Subject: [PATCH 07/33] fix: stop logging pairing offer during daemon startup --- packages/app/e2e/global-setup.ts | 88 +++++++++++-------- .../server/src/server/bootstrap.smoke.test.ts | 27 +++--- packages/server/src/server/bootstrap.ts | 3 +- .../daemon-e2e/connection-offer.e2e.test.ts | 66 +++++++++----- .../daemon-e2e/relay-transport.e2e.test.ts | 47 +++++++--- 5 files changed, 140 insertions(+), 91 deletions(-) diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 3ac61ee2e..9e319e851 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess, execSync } from "node:child_process"; +import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -224,6 +224,51 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload { return offer as OfferPayload; } +function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload { + const stdout = execFileSync( + process.execPath, + ["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"], + { + cwd: repoRoot, + env: { + ...process.env, + PASEO_HOME: paseoHomePath, + }, + encoding: "utf8", + }, + ); + const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null }; + if (payload.relayEnabled !== true || typeof payload.url !== "string") { + throw new Error(`Unexpected daemon pair response: ${stdout}`); + } + return decodeOfferFromFragmentUrl(payload.url); +} + +async function waitForPairingOfferFromCli(args: { + repoRoot: string; + paseoHome: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = args.timeoutMs ?? 15000; + const start = Date.now(); + let lastError: unknown = null; + + while (Date.now() - start < timeoutMs) { + try { + return loadPairingOfferFromCli(args.repoRoot, args.paseoHome); + } catch (error) { + lastError = error; + await sleep(100); + } + } + + throw new Error( + `Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +} + export default async function globalSetup() { const repoRoot = path.resolve(__dirname, "../../.."); ensureRelayBuildArtifact(repoRoot); @@ -433,12 +478,6 @@ export default async function globalSetup() { const serverDir = path.resolve(__dirname, "../../..", "packages/server"); const tsxBin = execSync("which tsx").toString().trim(); - let offerPayload: OfferPayload | null = null; - let offerResolve: (() => void) | null = null; - const offerPromise = new Promise((resolve) => { - offerResolve = resolve; - }); - daemonProcess = spawn(tsxBin, ["src/server/index.ts"], { cwd: serverDir, env: { @@ -473,26 +512,6 @@ export default async function globalSetup() { const trimmed = line.trim(); if (!trimmed) continue; daemonLineBuffer.add(`[stdout] ${trimmed}`); - if (!offerPayload) { - const clean = stripAnsi(trimmed); - try { - const obj = JSON.parse(clean) as { msg?: string; url?: string }; - if (obj.msg === "pairing_offer" && typeof obj.url === "string") { - offerPayload = decodeOfferFromFragmentUrl(obj.url); - offerResolve?.(); - } - } catch { - const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/); - if (match && clean.includes("pairing_offer")) { - try { - offerPayload = decodeOfferFromFragmentUrl(match[0]); - offerResolve?.(); - } catch { - // ignore parsing failures - } - } - } - } console.log(`[daemon] ${trimmed}`); } }); @@ -523,17 +542,10 @@ export default async function globalSetup() { }), ]); - // Wait for daemon to emit a pairing offer (includes relay session ID). - await Promise.race([ - offerPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000), - ), - ]); - if (!offerPayload) { - throw new Error("pairing_offer was not parsed from daemon logs"); - } - const offer = offerPayload as OfferPayload; + const offer = await waitForPairingOfferFromCli({ + repoRoot, + paseoHome, + }); process.env.E2E_DAEMON_PORT = String(port); process.env.E2E_RELAY_PORT = String(relayPort); diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index ee2b4e520..ccc42eccf 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,11 +1,11 @@ import os from "node:os"; import path from "node:path"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { Writable } from "node:stream"; import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; +import { generateLocalPairingOffer } from "./pairing-offer.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; @@ -150,24 +150,14 @@ describe("paseo daemon bootstrap", () => { }); }); - test("emits a relay pairing offer for unix socket listeners", async () => { + test("generates a relay pairing offer for unix socket listeners", async () => { const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-")); const paseoHome = path.join(paseoHomeRoot, ".paseo"); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock"); await mkdir(path.dirname(socketPath), { recursive: true }); await mkdir(paseoHome, { recursive: true }); - - const lines: string[] = []; - const logger = pino( - { level: "info" }, - new Writable({ - write(chunk, _encoding, callback) { - lines.push(chunk.toString("utf8")); - callback(); - }, - }), - ); + const logger = pino({ level: "silent" }); const config: PaseoDaemonConfig = { listen: socketPath, @@ -191,7 +181,16 @@ describe("paseo daemon bootstrap", () => { try { await daemon.start(); - expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true); + const pairing = await generateLocalPairingOffer({ + paseoHome, + relayEnabled: true, + relayEndpoint: "127.0.0.1:9", + relayPublicEndpoint: "127.0.0.1:9", + appBaseUrl: "https://app.paseo.sh", + includeQr: false, + }); + expect(pairing.relayEnabled).toBe(true); + expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true); } finally { await daemon.stop().catch(() => undefined); await daemon.agentManager.flush().catch(() => undefined); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 6a5e9b476..b224a674c 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -646,8 +646,7 @@ export async function createPaseoDaemon( relay: { endpoint: relayPublicEndpoint }, }); - const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); - logger.info({ url }, "pairing_offer"); + encodeOfferToFragmentUrl({ offer, appBaseUrl }); relayTransport?.stop().catch(() => undefined); relayTransport = startRelayTransport({ diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts index 80b464d3f..2c065c93c 100644 --- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts @@ -8,6 +8,7 @@ import { Writable } from "node:stream"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; function createCapturingLogger() { @@ -22,18 +23,25 @@ function createCapturingLogger() { return { logger, lines }; } -function parseOfferUrlFromLogs(lines: string[]): string { - for (const line of lines) { - try { - const obj = JSON.parse(line) as { msg?: string; url?: string }; - if (obj.msg === "pairing_offer" && typeof obj.url === "string") { - return obj.url; - } - } catch { - // ignore non-JSON lines - } +async function getPairingOfferUrl(args: { + paseoHome: string; + relayEnabled?: boolean; + relayEndpoint?: string; + relayPublicEndpoint?: string; + appBaseUrl?: string; +}): Promise { + const pairing = await generateLocalPairingOffer({ + paseoHome: args.paseoHome, + relayEnabled: args.relayEnabled, + relayEndpoint: args.relayEndpoint, + relayPublicEndpoint: args.relayPublicEndpoint, + appBaseUrl: args.appBaseUrl, + includeQr: false, + }); + if (!pairing.url) { + throw new Error("Expected relay pairing URL to be available"); } - throw new Error(`pairing_offer log not found. saw ${lines.length} lines`); + return pairing.url; } function decodeOfferFromFragmentUrl(url: string): unknown { @@ -72,7 +80,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { test("emits relay-only offer URL with stable serverId", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; - const { logger, lines } = createCapturingLogger(); + const { logger } = createCapturingLogger(); const daemon = await createTestPaseoDaemon({ listen: "0.0.0.0", @@ -81,7 +89,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { }); try { - const offerUrl = parseOfferUrlFromLogs(lines); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); expect(offerUrl.startsWith("https://app.paseo.sh/#offer=")).toBe(true); const offer = decodeOfferFromFragmentUrl(offerUrl) as { @@ -111,7 +125,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { const tempHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-home-")); - const { logger: logger1, lines: lines1 } = createCapturingLogger(); + const { logger: logger1 } = createCapturingLogger(); const daemon1 = await createTestPaseoDaemon({ listen: "0.0.0.0", logger: logger1, @@ -124,7 +138,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { let staticDir2: string | null = null; try { - const offerUrl1 = parseOfferUrlFromLogs(lines1); + const offerUrl1 = await getPairingOfferUrl({ + paseoHome: daemon1.paseoHome, + relayEnabled: daemon1.config.relayEnabled, + relayEndpoint: daemon1.config.relayEndpoint, + relayPublicEndpoint: daemon1.config.relayPublicEndpoint, + appBaseUrl: daemon1.config.appBaseUrl, + }); const offer1 = decodeOfferFromFragmentUrl(offerUrl1) as { serverId: string; daemonPublicKeyB64: string; @@ -133,7 +153,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { await daemon1.close(); - const { logger: logger2, lines: lines2 } = createCapturingLogger(); + const { logger: logger2 } = createCapturingLogger(); const daemon2 = await createTestPaseoDaemon({ listen: "0.0.0.0", logger: logger2, @@ -144,7 +164,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { staticDir2 = daemon2.staticDir; try { - const offerUrl2 = parseOfferUrlFromLogs(lines2); + const offerUrl2 = await getPairingOfferUrl({ + paseoHome: daemon2.paseoHome, + relayEnabled: daemon2.config.relayEnabled, + relayEndpoint: daemon2.config.relayEndpoint, + relayPublicEndpoint: daemon2.config.relayPublicEndpoint, + appBaseUrl: daemon2.config.appBaseUrl, + }); const offer2 = decodeOfferFromFragmentUrl(offerUrl2) as { serverId: string; daemonPublicKeyB64: string; @@ -207,12 +233,6 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { stdoutLines.push(text); for (const line of text.split("\n")) { if (!line.trim()) continue; - if (line.includes("pairing_offer")) { - clearTimeout(timeout); - reject(new Error("unexpected pairing_offer log when --no-relay is set")); - return; - } - try { const parsed = JSON.parse(line) as { msg?: string }; if (parsed.msg !== `Server listening on http://0.0.0.0:${port}`) continue; diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index ecc474c65..6241d7578 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { Buffer } from "node:buffer"; +import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js"; @@ -26,19 +27,25 @@ function createCapturingLogger() { return { logger, lines }; } -function parseOfferUrlFromLogs(lines: string[]): string { - for (const line of lines) { - if (!line.includes("pairing_offer")) continue; - try { - const obj = JSON.parse(line) as { msg?: string; url?: string }; - if (obj.msg === "pairing_offer" && typeof obj.url === "string") { - return obj.url; - } - } catch { - // ignore - } +async function getPairingOfferUrl(args: { + paseoHome: string; + relayEnabled?: boolean; + relayEndpoint?: string; + relayPublicEndpoint?: string; + appBaseUrl?: string; +}): Promise { + const pairing = await generateLocalPairingOffer({ + paseoHome: args.paseoHome, + relayEnabled: args.relayEnabled, + relayEndpoint: args.relayEndpoint, + relayPublicEndpoint: args.relayPublicEndpoint, + appBaseUrl: args.appBaseUrl, + includeQr: false, + }); + if (!pairing.url) { + throw new Error("Expected relay pairing URL to be available"); } - throw new Error(`pairing_offer log not found. saw ${lines.length} lines`); + return pairing.url; } function decodeOfferFromFragmentUrl(url: string): { @@ -199,7 +206,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis }); try { - const offerUrl = parseOfferUrlFromLogs(lines); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; @@ -314,7 +327,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis }); try { - const offerUrl = parseOfferUrlFromLogs(lines); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); // Previously, the daemon would time out waiting for `hello` and reconnect every ~10s. From 80eb5dbe83947b28cb7784769fc22de497c58e66 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 19:20:59 +0700 Subject: [PATCH 08/33] fix: make workspace-git-service tests platform-aware for Linux CI The requestWorkingTreeWatch tests assumed macOS/Windows behavior where recursive fs.watch is attempted. On Linux, the service uses per-directory watchers instead, so recursive is never tried. --- .../src/server/workspace-git-service.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 8d9ecc886..a792c1b6c 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -592,6 +592,13 @@ describe("WorkspaceGitServiceImpl", () => { }); test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => { + if (process.platform === "linux") { + // On Linux, recursive watch is never attempted — the service uses per-directory + // watchers from the start. This scenario only applies to macOS/Windows where + // recursive watch is tried first and may fail. + return; + } + const recursiveUnsupported = new Error("recursive unsupported"); const watch = vi .fn() @@ -629,7 +636,12 @@ describe("WorkspaceGitServiceImpl", () => { const target = (service as any).workingTreeWatchTargets.get("/tmp/plain"); expect(subscription.repoRoot).toBeNull(); - expect(watch).toHaveBeenCalledWith("/tmp/plain", { recursive: true }, expect.any(Function)); + const expectedRecursive = process.platform !== "linux"; + expect(watch).toHaveBeenCalledWith( + "/tmp/plain", + { recursive: expectedRecursive }, + expect.any(Function), + ); expect(target?.repoWatchPath).toBe("/tmp/plain"); expect(target?.fallbackRefreshInterval).not.toBeNull(); From d1702e8a40210f452ef41fb64f470b00eacf4fa6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 19:38:47 +0700 Subject: [PATCH 09/33] fix: defer worktree git watch subscription until after worktree exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerPendingWorktreeWorkspace was subscribing the WorkspaceGitService before the worktree directory existed on disk, caching a stale isGit:false snapshot. When background reconciliation ran, it consumed the stale cache, overwrote the correct workspace record with a wrong projectId, and briefly showed the worktree as a standalone project in the sidebar. Move syncWorkspaceGitWatchTarget from registerPendingWorktreeWorkspace into handleCreatePaseoWorktreeRequest, called after createAgentWorktree, so the first snapshot sees the real worktree — one subscribe, one load, no stale cache to race against. --- packages/server/src/server/session.ts | 4 ++-- packages/server/src/server/worktree-session.ts | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2a80b0e2d..0b0906f33 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5880,8 +5880,6 @@ export class Session { buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input), buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd), projectRegistry: this.projectRegistry, - syncWorkspaceGitWatchTarget: (cwd, syncOptions) => - this.syncWorkspaceGitWatchTarget(cwd, syncOptions), workspaceRegistry: this.workspaceRegistry, archiveProjectRecordIfEmpty: (projectId, archivedAt) => this.archiveProjectRecordIfEmpty(projectId, archivedAt), @@ -6269,6 +6267,8 @@ export class Session { emit: (message) => this.emit(message), registerPendingWorktreeWorkspace: (options) => this.registerPendingWorktreeWorkspace(options), + syncWorkspaceGitWatchTarget: (cwd, syncOptions) => + this.syncWorkspaceGitWatchTarget(cwd, syncOptions), sessionLogger: this.sessionLogger, runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options), }, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2b343e6c3..eae15c417 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -87,7 +87,6 @@ type RegisterPendingWorktreeWorkspaceDependencies = { }) => PersistedWorkspaceRecord; buildProjectPlacement: (cwd: string) => Promise; projectRegistry: Pick; - syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise; workspaceRegistry: Pick; archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise; }; @@ -111,6 +110,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = { worktreePath: string; branchName: string; }) => Promise; + syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise; sessionLogger: Logger; runWorktreeSetupInBackground: (options: { requestCwd: string; @@ -535,9 +535,6 @@ export async function registerPendingWorktreeWorkspace( await dependencies.projectRegistry.upsert(nextProjectRecord); await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord); - await dependencies.syncWorkspaceGitWatchTarget(workspaceId, { - isGit: placement.checkout.isGit, - }); if ( existingWorkspace && @@ -593,6 +590,8 @@ export async function handleCreatePaseoWorktreeRequest( paseoHome: dependencies.paseoHome, }); + await dependencies.syncWorkspaceGitWatchTarget(worktreePath, { isGit: true }); + const descriptor = await dependencies.describeWorkspaceRecord(workspace); dependencies.emit({ type: "create_paseo_worktree_response", From 8651c334c747c5c31f86c5d18f55820d50dafb72 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 19:43:08 +0700 Subject: [PATCH 10/33] fix: keep worktree creation spinner visible while loading The new-worktree button spinner was hidden when the cursor left the project row. Force visibility when the mutation is pending so users see the loading state throughout creation. --- packages/app/src/components/sidebar-workspace-list.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5347eaa85..181fd0981 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -808,7 +808,12 @@ function ProjectHeaderRow({ createWorktreeMutation.mutate()} - visible={isHovered || platformIsNative || isMobileBreakpoint} + visible={ + isHovered || + platformIsNative || + isMobileBreakpoint || + createWorktreeMutation.isPending + } loading={createWorktreeMutation.isPending} showShortcutHint={isProjectActive} testID={`sidebar-project-new-worktree-${project.projectKey}`} From 30c7729a7a7caa4f03cc1054d022261f218f86d8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 12:09:56 +0700 Subject: [PATCH 11/33] docs: add custom provider configuration guide Covers Z.AI (Zhipu), Alibaba Cloud (Qwen) coding plans, ACP providers (Gemini CLI, Hermes), multiple profiles, custom binaries, and disabling providers. All examples backed by official provider documentation. --- CLAUDE.md | 1 + docs/CUSTOM-PROVIDERS.md | 522 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 docs/CUSTOM-PROVIDERS.md diff --git a/CLAUDE.md b/CLAUDE.md index e76ec0aa8..2edfb88f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ This is an npm workspace monorepo: | [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization | | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist | +| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | | [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows | | [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation | | [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | diff --git a/docs/CUSTOM-PROVIDERS.md b/docs/CUSTOM-PROVIDERS.md new file mode 100644 index 000000000..3e1afa9a0 --- /dev/null +++ b/docs/CUSTOM-PROVIDERS.md @@ -0,0 +1,522 @@ +# Custom Provider Configuration + +Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider. + +All provider configuration lives under `agents.providers` in config.json: + +```json +{ + "version": 1, + "agents": { + "providers": { + "provider-id": { ... } + } + } +} +``` + +Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`). + +--- + +## Table of Contents + +- [Extending a built-in provider](#extending-a-built-in-provider) +- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan) +- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan) +- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider) +- [Custom binary for a provider](#custom-binary-for-a-provider) +- [Disabling a provider](#disabling-a-provider) +- [ACP providers](#acp-providers) +- [Provider override reference](#provider-override-reference) + +--- + +## Extending a built-in provider + +Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions. + +```json +{ + "agents": { + "providers": { + "my-claude": { + "extends": "claude", + "label": "My Claude", + "description": "Claude with custom API endpoint", + "env": { + "ANTHROPIC_API_KEY": "sk-ant-...", + "ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1" + } + } + } + } +} +``` + +Required fields for custom providers: +- `extends` — which built-in provider to inherit from (or `"acp"`) +- `label` — display name in the UI + +--- + +## Z.AI (Zhipu) coding plan + +[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API. + +### Setup + +1. Register at [z.ai](https://z.ai) and subscribe to a coding plan +2. Create an API key from the Z.AI dashboard +3. Add a provider entry in config.json: + +```json +{ + "agents": { + "providers": { + "zai": { + "extends": "claude", + "label": "ZAI", + "env": { + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", + "API_TIMEOUT_MS": "3000000" + }, + "models": [ + { "id": "glm-4.5-air", "label": "GLM 4.5 Air" }, + { "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true }, + { "id": "glm-5.1", "label": "GLM 5.1" } + ] + } + } + } +} +``` + +### Available models + +| Model | Tier | +|---|---| +| `glm-5.1` | Advanced (flagship) | +| `glm-5-turbo` | Advanced | +| `glm-4.7` | Standard | +| `glm-4.5-air` | Lightweight | + +### Notes + +- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key +- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic) +- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider +- Automated setup is also available: `npx @z_ai/coding-helper` +- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude) + +--- + +## Alibaba Cloud (Qwen) coding plan + +[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models. + +### Setup + +1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region) +2. Subscribe to the Pro plan ($50/month) +3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key +4. Add a provider entry in config.json: + +```json +{ + "agents": { + "providers": { + "qwen": { + "extends": "claude", + "label": "Qwen (Alibaba)", + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-sp-", + "ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + }, + "models": [ + { "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true }, + { "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }, + { "id": "kimi-k2.5", "label": "Kimi K2.5" } + ] + } + } + } +} +``` + +### API endpoints + +| Mode | Base URL | +|---|---| +| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | +| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` | + +For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`. + +### Available models + +**Recommended for coding plan:** + +| Model | Notes | +|---|---| +| `qwen3.5-plus` | Vision capable, recommended | +| `qwen3-coder-next` | Optimized for coding | +| `kimi-k2.5` | Vision capable | +| `glm-5` | Zhipu GLM | +| `MiniMax-M2.5` | MiniMax | + +**Additional models (pay-as-you-go):** +`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash` + +### Notes + +- API keys must be created in the **Singapore region** +- The coding plan is for personal use only in interactive coding tools +- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan) + +--- + +## Multiple profiles for the same provider + +You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment. + +Example: two different Anthropic accounts as separate profiles: + +```json +{ + "agents": { + "providers": { + "claude-work": { + "extends": "claude", + "label": "Claude (Work)", + "description": "Work Anthropic account", + "env": { + "ANTHROPIC_API_KEY": "sk-ant-work-..." + } + }, + "claude-personal": { + "extends": "claude", + "label": "Claude (Personal)", + "description": "Personal Anthropic account", + "env": { + "ANTHROPIC_API_KEY": "sk-ant-personal-..." + } + } + } + } +} +``` + +Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent. + +You can also combine profiles with model overrides to pin specific models per profile: + +```json +{ + "agents": { + "providers": { + "claude-fast": { + "extends": "claude", + "label": "Claude (Fast)", + "models": [ + { "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true } + ] + }, + "claude-smart": { + "extends": "claude", + "label": "Claude (Smart)", + "models": [ + { "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true } + ] + } + } + } +} +``` + +--- + +## Custom binary for a provider + +Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments. + +### Override a built-in provider's binary + +```json +{ + "agents": { + "providers": { + "claude": { + "command": ["/opt/claude-nightly/claude"] + } + } + } +} +``` + +### Use a custom wrapper script + +```json +{ + "agents": { + "providers": { + "claude": { + "command": ["/usr/local/bin/my-claude-wrapper", "--verbose"] + } + } + } +} +``` + +### Custom binary on a derived provider + +```json +{ + "agents": { + "providers": { + "my-codex": { + "extends": "codex", + "label": "Codex (Custom Build)", + "command": ["/home/user/codex-dev/target/release/codex"] + } + } + } +} +``` + +The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found. + +--- + +## Disabling a provider + +Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI. + +```json +{ + "agents": { + "providers": { + "copilot": { "enabled": false }, + "codex": { "enabled": false } + } + } +} +``` + +This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default). + +--- + +## ACP providers + +The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider. + +ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout. + +### Adding a generic ACP provider + +Set `extends: "acp"` and provide a `command`: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent-binary", "--acp"], + "env": { + "MY_API_KEY": "..." + } + } + } + } +} +``` + +Required fields for ACP providers: +- `extends: "acp"` +- `label` +- `command` — the command to spawn the agent process (must support ACP over stdio) + +### Example: Google Gemini CLI + +[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag. + +1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli) +2. Authenticate with Google (Gemini CLI handles its own auth) +3. Add to config.json: + +```json +{ + "agents": { + "providers": { + "gemini": { + "extends": "acp", + "label": "Google Gemini", + "command": ["gemini", "--acp"] + } + } + } +} +``` + +Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md) + +### Example: Hermes (Nous Research) + +[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand. + +1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash` +2. Install ACP support: `pip install -e '.[acp]'` +3. Configure Hermes credentials in `~/.hermes/` +4. Add to config.json: + +```json +{ + "agents": { + "providers": { + "hermes": { + "extends": "acp", + "label": "Hermes", + "description": "Nous Research self-improving AI agent", + "command": ["hermes", "acp"] + } + } + } +} +``` + +Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp) + +### How ACP providers work in Paseo + +When you launch an agent with an ACP provider: + +1. Paseo spawns the process using the configured `command` +2. Sends an `initialize` JSON-RPC request over stdin +3. The agent responds with its capabilities, available modes, and models +4. Paseo creates a session and sends prompts through the ACP protocol +5. The agent streams responses, tool calls, and permission requests back over stdout + +Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent", "--acp"], + "models": [ + { "id": "fast-model", "label": "Fast", "isDefault": true }, + { "id": "smart-model", "label": "Smart" } + ] + } + } + } +} +``` + +Profile models (defined in config.json) completely replace runtime-discovered models when present. + +--- + +## Provider override reference + +Every entry under `agents.providers` accepts these fields: + +| Field | Type | Required | Description | +|---|---|---|---| +| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` | +| `label` | `string` | Yes (custom only) | Display name in the UI | +| `description` | `string` | No | Short description shown in the UI | +| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | +| `env` | `Record` | No | Environment variables to set for the agent process | +| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | +| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) | +| `order` | `number` | No | Sort order in the provider list | + +### Model definition + +Each entry in the `models` array: + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | `string` | Yes | Model identifier sent to the provider | +| `label` | `string` | Yes | Display name in the UI | +| `description` | `string` | No | Short description | +| `isDefault` | `boolean` | No | Mark as the default model selection | +| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels | + +### Thinking option + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | `string` | Yes | Thinking option identifier | +| `label` | `string` | Yes | Display name | +| `description` | `string` | No | Short description | +| `isDefault` | `boolean` | No | Mark as the default thinking option | + +### Valid `extends` values + +Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi` + +Special value: `acp` — creates a generic ACP provider (requires `command`) + +### Full example + +A config.json with multiple custom providers: + +```json +{ + "version": 1, + "agents": { + "providers": { + "copilot": { "enabled": false }, + + "zai": { + "extends": "claude", + "label": "ZAI", + "env": { + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", + "API_TIMEOUT_MS": "3000000" + }, + "models": [ + { "id": "glm-4.5-air", "label": "GLM 4.5 Air" }, + { "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true }, + { "id": "glm-5.1", "label": "GLM 5.1" } + ] + }, + + "qwen": { + "extends": "claude", + "label": "Qwen (Alibaba)", + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-sp-", + "ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + }, + "models": [ + { "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true }, + { "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" } + ] + }, + + "gemini": { + "extends": "acp", + "label": "Google Gemini", + "command": ["gemini", "--acp"] + }, + + "hermes": { + "extends": "acp", + "label": "Hermes", + "command": ["hermes", "acp"] + } + } + } +} +``` From 649eddeea8f3c810c7b91e1b4e0701daff801c86 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 20:12:12 +0700 Subject: [PATCH 12/33] docs: add 0.1.55 changelog --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 117fa1869..f0b18ce6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.1.55 - 2026-04-14 + +### Added +- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md). +- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed. +- Choose provider and model when creating scheduled agents. +- Max reasoning effort option for Opus 4.6 models. +- Cmd+, (Ctrl+, on Windows/Linux) opens settings. + +### Improved +- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth. +- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling. +- iPad and tablet layouts work correctly across all screen sizes. +- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter. + +### Fixed +- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository. +- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out. +- Workspace navigation updates correctly when switching between workspaces in the same project. +- Desktop workspace header alignment and model selector no longer overflow on narrow windows. +- Loading indicators are visible in light mode. + ## 0.1.54 - 2026-04-12 ### Added From c70277f445bc742a22b4266abe13591c9731fafd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 20:15:14 +0700 Subject: [PATCH 13/33] chore(release): cut 0.1.55 --- 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 e30eb6d10..9b3e353e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.55-rc.2", + "version": "0.1.55", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.55-rc.2", + "version": "0.1.55", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.55-rc.2", + "version": "0.1.55", "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.55-rc.2", - "@getpaseo/highlight": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/expo-two-way-audio": "0.1.55", + "@getpaseo/highlight": "0.1.55", + "@getpaseo/server": "0.1.55", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35056,11 +35056,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.55-rc.2", + "version": "0.1.55", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/relay": "0.1.55", + "@getpaseo/server": "0.1.55", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35101,11 +35101,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.55-rc.2", + "version": "0.1.55", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/cli": "0.1.55", + "@getpaseo/server": "0.1.55", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35139,7 +35139,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.55-rc.2", + "version": "0.1.55", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35340,7 +35340,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.55-rc.2", + "version": "0.1.55", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35366,7 +35366,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.55-rc.2", + "version": "0.1.55", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35382,14 +35382,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.55-rc.2", + "version": "0.1.55", "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.55-rc.2", - "@getpaseo/relay": "0.1.55-rc.2", + "@getpaseo/highlight": "0.1.55", + "@getpaseo/relay": "0.1.55", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35817,7 +35817,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.55-rc.2", + "version": "0.1.55", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 904ab52fb..41406d9c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.55-rc.2", + "version": "0.1.55", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 49d6695d4..976b705d4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.55-rc.2", + "version": "0.1.55", "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.55-rc.2", - "@getpaseo/highlight": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/expo-two-way-audio": "0.1.55", + "@getpaseo/highlight": "0.1.55", + "@getpaseo/server": "0.1.55", "@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 7b985de8d..6b3d230f0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.55-rc.2", + "version": "0.1.55", "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.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/relay": "0.1.55", + "@getpaseo/server": "0.1.55", "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 7cf98e142..92c479ed3 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.55-rc.2", + "version": "0.1.55", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -13,8 +13,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/cli": "0.1.55", + "@getpaseo/server": "0.1.55", "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 7e948d764..9156ea3f5 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.55-rc.2", + "version": "0.1.55", "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 4a5ac2be0..6ac764799 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.55-rc.2", + "version": "0.1.55", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index b31fa4141..8ab37c0c7 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.55-rc.2", + "version": "0.1.55", "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 9260d9971..6155d9445 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.55-rc.2", + "version": "0.1.55", "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.55-rc.2", - "@getpaseo/relay": "0.1.55-rc.2", + "@getpaseo/highlight": "0.1.55", + "@getpaseo/relay": "0.1.55", "@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 186ba4832..14d50a238 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.55-rc.2", + "version": "0.1.55", "private": true, "type": "module", "scripts": { From 2090d685ed60315c421e3ccfc39aa34128bb3f6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 13:16:33 +0000 Subject: [PATCH 14/33] 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 efb18e9e1..aaeed35b4 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-AyhJwa2ISs3wxorZIWblYasH35K3YpesPl4+1f5QTJg="; + npmDepsHash = "sha256-eO9RqIiHUAiaLmYFoSwOYoWOLqiPgK41O0cNTSmZaHQ="; # 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 25fd93d7e2f1443c1c0f918204cdd343e1804d63 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 20:49:54 +0700 Subject: [PATCH 15/33] fix: handle repos with no commits in getCurrentBranch git rev-parse --abbrev-ref HEAD fails with exit code 128 on freshly initialized repos with no commits. This error bubbled up through workspace listing and showed a toast error on every app launch for users with empty git repos. --- packages/server/src/server/worktree-session.ts | 11 +++-------- packages/server/src/utils/checkout-git.test.ts | 10 ++++++++++ packages/server/src/utils/checkout-git.ts | 16 ++++++++++------ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index eae15c417..e45b507e9 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -21,7 +21,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; import { createAgentWorktree } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import { resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; +import { getCurrentBranch, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { computeWorktreePath, @@ -35,8 +35,7 @@ import { validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; -import { runGitCommand } from "../utils/run-git-command.js"; -import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js"; +import { toCheckoutError } from "./checkout-git-utils.js"; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; export type NormalizedGitOptions = { @@ -153,11 +152,7 @@ export async function buildAgentSessionConfig( if (normalized.createNewBranch) { targetBranch = normalized.newBranchName!; } else { - const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd, - env: READ_ONLY_GIT_ENV, - }); - targetBranch = stdout.trim(); + targetBranch = (await getCurrentBranch(cwd)) ?? ""; } if (!targetBranch) { diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 3ce01f7cc..5f4f5ba8a 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -18,6 +18,7 @@ import { __setGhPathForTests, __setPullRequestStatusCacheTtlForTests, commitAll, + getCurrentBranch, getCheckoutDiff, getCheckoutShortstat, getPullRequestStatus, @@ -84,6 +85,15 @@ describe("checkout git utilities", () => { ); }); + it("returns null for getCurrentBranch in a repo with no commits", async () => { + const emptyRepo = join(tempDir, "empty-repo"); + execSync(`mkdir -p ${emptyRepo}`); + execSync("git init -b main", { cwd: emptyRepo }); + + const branch = await getCurrentBranch(emptyRepo); + expect(branch).toBeNull(); + }); + it("handles status/diff/commit in a normal repo", async () => { writeFileSync(join(repoDir, "file.txt"), "updated\n"); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 19669e8bc..7b2567947 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -525,12 +525,16 @@ async function requireGitRepo(cwd: string): Promise { } export async function getCurrentBranch(cwd: string): Promise { - const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const branch = stdout.trim(); - return branch.length > 0 ? branch : null; + try { + const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const branch = stdout.trim(); + return branch.length > 0 ? branch : null; + } catch { + return null; + } } async function getWorktreeRoot(cwd: string): Promise { From f0ac6327329015127820426b129dabda4dc1bc6d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 20:54:11 +0700 Subject: [PATCH 16/33] fix: isolate git snapshot errors per workspace in fetch_workspaces A single workspace failing to load git data (e.g. empty repo, corrupt git state) was crashing the entire fetch_workspaces response, leaving all users with an unusable app. Now errors are caught per-workspace and logged as warnings, returning the workspace without git data. --- packages/server/src/server/session.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0b0906f33..1dcc805e9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5443,7 +5443,17 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - const snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd); + + let snapshot: WorkspaceGitRuntimeSnapshot; + try { + snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd); + } catch (error) { + this.sessionLogger.warn( + { err: error, cwd: workspace.cwd }, + "Failed to load git snapshot for workspace", + ); + return base; + } const checkout = checkoutLiteFromGitSnapshot(workspace.cwd, snapshot.git); const displayName = deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout }); From c8cafbabff62cda82083418fb2ab4b72c002f8b3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 20:56:11 +0700 Subject: [PATCH 17/33] chore(release): cut 0.1.56-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 9b3e353e8..534ff0201 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.55", + "version": "0.1.56-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.55", + "version": "0.1.56-rc.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.55", + "version": "0.1.56-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.55", - "@getpaseo/highlight": "0.1.55", - "@getpaseo/server": "0.1.55", + "@getpaseo/expo-two-way-audio": "0.1.56-rc.1", + "@getpaseo/highlight": "0.1.56-rc.1", + "@getpaseo/server": "0.1.56-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35056,11 +35056,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.55", + "version": "0.1.56-rc.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.55", - "@getpaseo/server": "0.1.55", + "@getpaseo/relay": "0.1.56-rc.1", + "@getpaseo/server": "0.1.56-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35101,11 +35101,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.55", + "version": "0.1.56-rc.1", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.55", - "@getpaseo/server": "0.1.55", + "@getpaseo/cli": "0.1.56-rc.1", + "@getpaseo/server": "0.1.56-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35139,7 +35139,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.55", + "version": "0.1.56-rc.1", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35340,7 +35340,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.55", + "version": "0.1.56-rc.1", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35366,7 +35366,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.55", + "version": "0.1.56-rc.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35382,14 +35382,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.55", + "version": "0.1.56-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.55", - "@getpaseo/relay": "0.1.55", + "@getpaseo/highlight": "0.1.56-rc.1", + "@getpaseo/relay": "0.1.56-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35817,7 +35817,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.55", + "version": "0.1.56-rc.1", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 41406d9c0..a5a529aa9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.55", + "version": "0.1.56-rc.1", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 976b705d4..de859bb44 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.55", + "version": "0.1.56-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.55", - "@getpaseo/highlight": "0.1.55", - "@getpaseo/server": "0.1.55", + "@getpaseo/expo-two-way-audio": "0.1.56-rc.1", + "@getpaseo/highlight": "0.1.56-rc.1", + "@getpaseo/server": "0.1.56-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 6b3d230f0..82861b256 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.55", + "version": "0.1.56-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.55", - "@getpaseo/server": "0.1.55", + "@getpaseo/relay": "0.1.56-rc.1", + "@getpaseo/server": "0.1.56-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 92c479ed3..3f1e92c86 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.55", + "version": "0.1.56-rc.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -13,8 +13,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.55", - "@getpaseo/server": "0.1.55", + "@getpaseo/cli": "0.1.56-rc.1", + "@getpaseo/server": "0.1.56-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 9156ea3f5..8096c0845 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.55", + "version": "0.1.56-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 6ac764799..2cd40a136 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.55", + "version": "0.1.56-rc.1", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 8ab37c0c7..0409ac496 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.55", + "version": "0.1.56-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 6155d9445..d52cfca12 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.55", + "version": "0.1.56-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.55", - "@getpaseo/relay": "0.1.55", + "@getpaseo/highlight": "0.1.56-rc.1", + "@getpaseo/relay": "0.1.56-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 14d50a238..3f75dcf1b 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.55", + "version": "0.1.56-rc.1", "private": true, "type": "module", "scripts": { From 32b68cbcf4ed05aecddf46ff17f90465148e7305 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 13:57:52 +0000 Subject: [PATCH 18/33] 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 aaeed35b4..5eef96dc3 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-eO9RqIiHUAiaLmYFoSwOYoWOLqiPgK41O0cNTSmZaHQ="; + npmDepsHash = "sha256-1mnfVYAAGP6x9ywFHEQ55q4i/C9uuZ2foU1ytacRMrE="; # 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 d4a40158049ad6eeed641ddfaa44c2defe621ccf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 21:15:53 +0700 Subject: [PATCH 19/33] chore(release): cut 0.1.56 --- 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 534ff0201..fb390f0b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.56-rc.1", + "version": "0.1.56", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.56-rc.1", + "version": "0.1.56", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.56-rc.1", + "version": "0.1.56", "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.56-rc.1", - "@getpaseo/highlight": "0.1.56-rc.1", - "@getpaseo/server": "0.1.56-rc.1", + "@getpaseo/expo-two-way-audio": "0.1.56", + "@getpaseo/highlight": "0.1.56", + "@getpaseo/server": "0.1.56", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35056,11 +35056,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.56-rc.1", + "version": "0.1.56", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.56-rc.1", - "@getpaseo/server": "0.1.56-rc.1", + "@getpaseo/relay": "0.1.56", + "@getpaseo/server": "0.1.56", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35101,11 +35101,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.56-rc.1", + "version": "0.1.56", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.56-rc.1", - "@getpaseo/server": "0.1.56-rc.1", + "@getpaseo/cli": "0.1.56", + "@getpaseo/server": "0.1.56", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35139,7 +35139,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.56-rc.1", + "version": "0.1.56", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35340,7 +35340,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.56-rc.1", + "version": "0.1.56", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35366,7 +35366,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.56-rc.1", + "version": "0.1.56", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35382,14 +35382,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.56-rc.1", + "version": "0.1.56", "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.56-rc.1", - "@getpaseo/relay": "0.1.56-rc.1", + "@getpaseo/highlight": "0.1.56", + "@getpaseo/relay": "0.1.56", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35817,7 +35817,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.56-rc.1", + "version": "0.1.56", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index a5a529aa9..6b4a332d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.56-rc.1", + "version": "0.1.56", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index de859bb44..72224ae52 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.56-rc.1", + "version": "0.1.56", "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.56-rc.1", - "@getpaseo/highlight": "0.1.56-rc.1", - "@getpaseo/server": "0.1.56-rc.1", + "@getpaseo/expo-two-way-audio": "0.1.56", + "@getpaseo/highlight": "0.1.56", + "@getpaseo/server": "0.1.56", "@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 82861b256..bab89f940 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.56-rc.1", + "version": "0.1.56", "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.56-rc.1", - "@getpaseo/server": "0.1.56-rc.1", + "@getpaseo/relay": "0.1.56", + "@getpaseo/server": "0.1.56", "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 3f1e92c86..d251e2a9f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.56-rc.1", + "version": "0.1.56", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -13,8 +13,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.56-rc.1", - "@getpaseo/server": "0.1.56-rc.1", + "@getpaseo/cli": "0.1.56", + "@getpaseo/server": "0.1.56", "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 8096c0845..454748fa5 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.56-rc.1", + "version": "0.1.56", "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 2cd40a136..76cd85999 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.56-rc.1", + "version": "0.1.56", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 0409ac496..d33a39760 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.56-rc.1", + "version": "0.1.56", "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 d52cfca12..6fae3809a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.56-rc.1", + "version": "0.1.56", "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.56-rc.1", - "@getpaseo/relay": "0.1.56-rc.1", + "@getpaseo/highlight": "0.1.56", + "@getpaseo/relay": "0.1.56", "@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 3f75dcf1b..055c9800c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.56-rc.1", + "version": "0.1.56", "private": true, "type": "module", "scripts": { From 200453f032f30f42b80542efaacc9095c8d70b22 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 21:17:12 +0700 Subject: [PATCH 20/33] docs: add 0.1.56 changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b18ce6c..1774840e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.56 - 2026-04-14 + +### Fixed +- Projects with empty git repositories (no commits yet) no longer crash the app on startup. +- A single problematic project can no longer prevent the rest of your workspaces from loading. + ## 0.1.55 - 2026-04-14 ### Added From 7f92f7f51d915b8e13eb2becfbcaafb955909c10 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 22:27:06 +0700 Subject: [PATCH 21/33] fix: stabilize branch switcher layout on mobile Always render the GitBranch icon regardless of branch availability so the layout doesn't shift when the branch name loads. Only the chevron remains dynamic. Also removes vertical padding on mobile to tighten the gap between the branch row and project subtitle. --- .../app/src/components/branch-switcher.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index f9ce26bf9..aeef7b346 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -43,12 +43,17 @@ export function BranchSwitcher({ queryClient, }); - if (!currentBranchName) { - return ( + const titleContent = ( + <> + {title} - ); + + ); + + if (!currentBranchName) { + return {titleContent}; } return ( @@ -63,10 +68,7 @@ export function BranchSwitcher({ accessibilityRole="button" accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`} > - - - {title} - + {titleContent} {!isCompact ? : null} ({ xs: -theme.spacing[2], md: 0, }, - paddingVertical: theme.spacing[1], + paddingVertical: { + xs: 0, + md: theme.spacing[1], + }, paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, flexShrink: 1, From 312a6e22fe3cd2e31e7118a5712d058f8646ccc9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 23:03:23 +0700 Subject: [PATCH 22/33] fix: flush head before applying canonical catch-up entries on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mobile, the server drops live stream events for backgrounded or unfocused agents. When the user returns, a seq gap triggers a catch-up fetch whose canonical entries are appended to the tail — but the head was never flushed. Stale live items from before the gap stayed in the head and rendered after the newer catch-up entries, breaking chronological order. Worse, subsequent live events of the same kind (e.g. assistant_message) would append to the stale head item, garbling message content. Now the incremental path in processTimelineResponse flushes head → tail before reducing canonical entries, keeping the timeline ordered and the head clean for new live events. --- .../src/contexts/session-stream-reducers.ts | 21 +++++++++++++++++-- packages/app/src/types/stream.ts | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/app/src/contexts/session-stream-reducers.ts b/packages/app/src/contexts/session-stream-reducers.ts index 6623fba94..c7fb61e51 100644 --- a/packages/app/src/contexts/session-stream-reducers.ts +++ b/packages/app/src/contexts/session-stream-reducers.ts @@ -1,7 +1,12 @@ import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { StreamItem } from "@/types/stream"; -import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; +import { + applyStreamEvent, + flushHeadToTail, + hydrateStreamState, + reduceStreamUpdate, +} from "@/types/stream"; import { classifySessionTimelineSeq, type SessionTimelineSeqDecision, @@ -214,12 +219,24 @@ export function processTimelineResponse( } if (acceptedUnits.length > 0) { + // Flush head to tail before appending canonical entries so that + // chronological ordering is preserved. This matters on mobile where + // the server drops live events for backgrounded/unfocused agents: + // when the client catches up, the head may still hold stale live + // items from before the gap that must be committed ahead of the + // canonical gap-fill entries. + const baseTail = + currentHead.length > 0 ? flushHeadToTail(currentTail, currentHead) : currentTail; + if (currentHead.length > 0) { + nextHead = []; + } + nextTail = acceptedUnits.reduce( (state, { event, timestamp }) => reduceStreamUpdate(state, event, timestamp, { source: "canonical", }), - currentTail, + baseTail, ); } diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 677667a2e..86f979870 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -729,7 +729,7 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] { /** * Flush head items to tail, avoiding duplicates. */ -function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] { +export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] { if (head.length === 0) { return tail; } From e012c8f52e7173a1fe6bfaa96ac74832a291c6d1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 23:21:37 +0700 Subject: [PATCH 23/33] feat: return agent snapshots from cancel and clear-attention RPCs Convert cancel_agent_request and clear_agent_attention from fire-and-forget to request/response RPCs that return the authoritative agent snapshot. This enables client-side self-healing when agent_update messages are missed (e.g. agent appears stuck running when it isn't). --- packages/server/src/client/daemon-client.ts | 46 +++++++++++++++++++-- packages/server/src/server/session.ts | 41 ++++++++++++++++-- packages/server/src/shared/messages.ts | 26 ++++++++++++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 2d7ba7db4..16edd9038 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -1187,8 +1187,28 @@ export class DaemonClient { } } - clearAgentAttention(agentId: string | string[]): void { - this.sendSessionMessage({ type: "clear_agent_attention", agentId }); + async clearAgentAttention(agentId: string | string[]): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "clear_agent_attention", + agentId, + requestId, + }); + await this.sendRequest({ + requestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "clear_agent_attention_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); } sendHeartbeat(params: { @@ -1692,7 +1712,27 @@ export class DaemonClient { } async cancelAgent(agentId: string): Promise { - this.sendSessionMessage({ type: "cancel_agent_request", agentId }); + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "cancel_agent_request", + agentId, + requestId, + }); + await this.sendRequest({ + requestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "cancel_agent_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); } async setAgentMode(agentId: string, modeId: string): Promise { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1dcc805e9..e141ceddf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1752,7 +1752,7 @@ export class Session { break; case "cancel_agent_request": - await this.handleCancelAgentRequest(msg.agentId); + await this.handleCancelAgentRequest(msg.agentId, msg.requestId); break; case "restart_server_request": @@ -1933,7 +1933,7 @@ export class Session { break; case "clear_agent_attention": - await this.handleClearAgentAttention(msg.agentId); + await this.handleClearAgentAttention(msg.agentId, msg.requestId); break; case "client_heartbeat": @@ -3189,11 +3189,23 @@ export class Session { } } - private async handleCancelAgentRequest(agentId: string): Promise { + private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise { this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`); try { await this.interruptAgentIfRunning(agentId); + if (requestId) { + const agent = this.agentManager.getAgent(agentId); + const payload = agent ? await this.buildAgentPayload(agent) : null; + this.emit({ + type: "cancel_agent_response", + payload: { + requestId, + agentId, + agent: payload, + }, + }); + } } catch (error) { this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request"); } @@ -3857,11 +3869,32 @@ export class Session { /** * Handle clearing agent attention flag */ - private async handleClearAgentAttention(agentId: string | string[]): Promise { + private async handleClearAgentAttention( + agentId: string | string[], + requestId?: string, + ): Promise { const agentIds = Array.isArray(agentId) ? agentId : [agentId]; try { await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id))); + if (requestId) { + const agents = ( + await Promise.all( + agentIds.map(async (id) => { + const agent = this.agentManager.getAgent(id); + return agent ? this.buildAgentPayload(agent) : null; + }), + ) + ).filter((payload): payload is NonNullable => payload !== null); + this.emit({ + type: "clear_agent_attention_response", + payload: { + requestId, + agentId, + agents, + }, + }); + } } catch (error: any) { this.sessionLogger.error({ err: error, agentIds }, "Failed to clear agent attention"); // Don't throw - this is not critical diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 63fdd7e6b..af2280dd7 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -876,6 +876,7 @@ export const RefreshAgentRequestMessageSchema = z.object({ export const CancelAgentRequestMessageSchema = z.object({ type: z.literal("cancel_agent_request"), agentId: z.string(), + requestId: z.string().optional(), }); export const RestartServerRequestMessageSchema = z.object({ @@ -1307,6 +1308,7 @@ export const FileDownloadTokenRequestSchema = z.object({ export const ClearAgentAttentionMessageSchema = z.object({ type: z.literal("clear_agent_attention"), agentId: z.union([z.string(), z.array(z.string())]), + requestId: z.string().optional(), }); export const ClientHeartbeatMessageSchema = z.object({ @@ -2102,6 +2104,24 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ }), }); +export const CancelAgentResponseMessageSchema = z.object({ + type: z.literal("cancel_agent_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + }), +}); + +export const ClearAgentAttentionResponseMessageSchema = z.object({ + type: z.literal("clear_agent_attention_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string().or(z.array(z.string())), + agents: z.array(AgentSnapshotPayloadSchema), + }), +}); + export const SendAgentMessageResponseMessageSchema = z.object({ type: z.literal("send_agent_message_response"), payload: z.object({ @@ -2771,6 +2791,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, + CancelAgentResponseMessageSchema, + ClearAgentAttentionResponseMessageSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, GetDaemonConfigResponseMessageSchema, @@ -2883,6 +2905,7 @@ export type FetchAgentResponseMessage = z.infer; +export type CancelAgentResponseMessage = z.infer; export type SendAgentMessageResponseMessage = z.infer; export type SetVoiceModeResponseMessage = z.infer; export type SetAgentModeResponseMessage = z.infer; @@ -3048,6 +3071,9 @@ export type FileDownloadTokenResponse = z.infer; export type ShutdownServerRequestMessage = z.infer; export type ClearAgentAttentionMessage = z.infer; +export type ClearAgentAttentionResponseMessage = z.infer< + typeof ClearAgentAttentionResponseMessageSchema +>; export type ClientHeartbeatMessage = z.infer; export type ListCommandsRequest = z.infer; export type ListCommandsResponse = z.infer; From f59af4c810d8f24863ce329d7d14464f72a6a2dc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 09:16:09 +0700 Subject: [PATCH 24/33] fix: catch unhandled timeout rejection in attention clear clearAgentAttention returns a promise that was never caught, causing uncaught rejection errors on daemon timeout. --- packages/app/src/hooks/use-agent-attention-clear.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/hooks/use-agent-attention-clear.ts b/packages/app/src/hooks/use-agent-attention-clear.ts index 78a1ea5eb..3c268c8cd 100644 --- a/packages/app/src/hooks/use-agent-attention-clear.ts +++ b/packages/app/src/hooks/use-agent-attention-clear.ts @@ -59,7 +59,7 @@ export function useAgentAttentionClear({ return; } deferredFocusEntryClearRef.current = false; - client.clearAgentAttention(resolvedAgentId); + client.clearAgentAttention(resolvedAgentId).catch(() => {}); }, [agentId, attentionReason, client, isConnected, requiresAttention], ); From c5295a4ab35b6c3b77efee489234f684b1b67792 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Wed, 15 Apr 2026 12:52:15 +1000 Subject: [PATCH 25/33] fix(server): archive OpenCode sessions on close (#408) Reconcile OpenCode provider state when Paseo closes or archives an agent so upstream sessions do not remain active after local teardown. Keep the close path idempotent by attempting an abort first and then marking the upstream session archived even when the run is already idle or missing. Fixes #400 Co-authored-by: OpenCode --- .../agent/providers/opencode-agent.test.ts | 59 +++++++++++++++ .../server/agent/providers/opencode-agent.ts | 74 +++++++++++++++++++ 2 files changed, 133 insertions(+) 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 e6e38f560..e478a2ea2 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,65 @@ const hasOpenCode = isBinaryInstalled("opencode"); }); describe("OpenCode adapter context-window normalization", () => { + test("close reconciliation aborts then archives upstream session", async () => { + const abort = vi.fn().mockResolvedValue({ data: true, error: undefined }); + const update = vi.fn().mockResolvedValue({ + data: { id: "session-1", time: { archived: Date.now() } }, + error: undefined, + }); + + await __openCodeInternals.reconcileOpenCodeSessionClose({ + client: { + session: { + abort, + update, + }, + } as never, + sessionId: "session-1", + directory: "/tmp/project", + logger: createTestLogger(), + }); + + expect(abort).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/tmp/project", + }); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/tmp/project", + time: { + archived: expect.any(Number), + }, + }); + }); + + test("close reconciliation still archives when abort returns an error", async () => { + const abort = vi.fn().mockResolvedValue({ + data: undefined, + error: { data: {}, errors: [], success: false }, + }); + const update = vi.fn().mockResolvedValue({ + data: { id: "session-1", time: { archived: Date.now() } }, + error: undefined, + }); + + await __openCodeInternals.reconcileOpenCodeSessionClose({ + client: { + session: { + abort, + update, + }, + } as never, + sessionId: "session-1", + directory: "/tmp/project", + logger: createTestLogger(), + }); + + expect(abort).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + test("builds OpenCode file parts for image prompt blocks", () => { expect( __openCodeInternals.buildOpenCodePromptParts([ diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index cc3ca96e8..3e036bd8c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -231,6 +231,73 @@ function normalizeTurnFailureError(error: unknown): string { return normalized.length > 0 ? normalized : "Unknown error"; } +function isOpenCodeNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "NotFoundError" + ); +} + +async function reconcileOpenCodeSessionClose(params: { + client: Pick; + sessionId: string; + directory: string; + logger: Logger; +}): Promise { + const { client, sessionId, directory, logger } = params; + + try { + const response = await client.session.abort({ + sessionID: sessionId, + directory, + }); + if (response.error && !isOpenCodeNotFoundError(response.error)) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(response.error), + }, + "Failed to abort OpenCode session during close", + ); + } + } catch (error) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(error), + }, + "Failed to abort OpenCode session during close", + ); + } + + try { + const response = await client.session.update({ + sessionID: sessionId, + directory, + time: { archived: Date.now() }, + }); + if (response.error && !isOpenCodeNotFoundError(response.error)) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(response.error), + }, + "Failed to archive OpenCode session during close", + ); + } + } catch (error) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(error), + }, + "Failed to archive OpenCode session during close", + ); + } +} + function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean { const normalized = typeof message === "string" ? message.trim().toLowerCase() : ""; if (!normalized) { @@ -570,6 +637,7 @@ export const __openCodeInternals = { hasNormalizedOpenCodeUsage, mergeOpenCodeStepFinishUsage, parseOpenCodeModelLookupKey, + reconcileOpenCodeSessionClose, resolveOpenCodeModelLookupKeyFromAssistantMessage, resolveOpenCodeSelectedModelContextWindow, }; @@ -1932,6 +2000,12 @@ class OpenCodeAgentSession implements AgentSession { async close(): Promise { this.abortController?.abort(); + await reconcileOpenCodeSessionClose({ + client: this.client, + sessionId: this.sessionId, + directory: this.config.cwd, + logger: this.logger, + }); this.subscribers.clear(); this.activeForegroundTurnId = null; } From 8f04e65cf12f1f7caad293f5b54d69b1be355118 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Wed, 15 Apr 2026 12:52:58 +1000 Subject: [PATCH 26/33] fix(opencode): Wait for SSE after slash command timeouts (#407) Treat OpenCode slash-command header timeouts as recoverable transport failures so Paseo waits for the SSE terminal event instead of failing the turn immediately. Add a regression test for the timeout path to keep slash commands aligned with the existing event-stream completion flow. --- ...encode-agent.slash-command-timeout.test.ts | 86 +++++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 71 +++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts diff --git a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts new file mode 100644 index 000000000..a57590f38 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts @@ -0,0 +1,86 @@ +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"; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("OpenCodeAgentSession slash command timeout handling", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("waits for SSE completion when slash commands hit a header timeout", async () => { + const idleEventGate = createDeferred(); + + vi.mocked(createOpencodeClient).mockReturnValue({ + session: { + create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }), + command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")), + }, + provider: { + list: vi.fn().mockResolvedValue({ + data: { + connected: ["openai"], + all: [{ id: "openai", name: "OpenAI", models: {} }], + }, + }), + }, + event: { + subscribe: vi.fn().mockResolvedValue({ + stream: (async function* () { + await idleEventGate.promise; + yield { + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + }), + }, + command: { + list: vi.fn().mockResolvedValue({ + data: [{ name: "help", description: "Show help", hints: [] }], + }), + }, + app: { + agents: vi.fn().mockResolvedValue({ data: [] }), + }, + } 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 session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); + + const runPromise = session.run("/help"); + await Promise.resolve(); + idleEventGate.resolve(); + + await expect(runPromise).resolves.toMatchObject({ + sessionId: "session-1", + finalText: "", + timeline: [], + usage: undefined, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3e036bd8c..4aea0eb2e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -107,6 +107,12 @@ const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [ "does not exist", "unsupported model", ] as const; +const OPENCODE_HEADERS_TIMEOUT_TOKENS = [ + "headers timeout", + "headers timeout error", + "headers_timeout", + "und_err_headers_timeout", +] as const; const OpencodeToolStateSchema = z .object({ @@ -306,6 +312,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token)); } +function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean { + const diagnostics = new Set(); + const queue: unknown[] = [error]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + + const normalized = stringifyUnknownError(current).trim().toLowerCase(); + if (normalized) { + diagnostics.add(normalized); + } + + if (typeof current === "object") { + const record = current as { + message?: unknown; + code?: unknown; + name?: unknown; + cause?: unknown; + }; + + for (const value of [record.message, record.code, record.name]) { + if (typeof value === "string") { + const diagnostic = value.trim().toLowerCase(); + if (diagnostic) { + diagnostics.add(diagnostic); + } + } + } + + if (record.cause) { + queue.push(record.cause); + } + } + } + + return [...diagnostics].some((diagnostic) => + OPENCODE_HEADERS_TIMEOUT_TOKENS.some((token) => diagnostic.includes(token)), + ); +} + function isAlreadyPresentMcpError(error: unknown): boolean { const normalized = stringifyUnknownError(error).toLowerCase(); return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token)); @@ -1598,6 +1647,17 @@ class OpenCodeAgentSession implements AgentSession { }) .then((response) => { if (response.error) { + if (isOpenCodeHeadersTimeoutFailure(response.error)) { + this.logger.warn( + { + err: response.error, + commandName: slashCommand.commandName, + turnId, + }, + "OpenCode slash command hit a header timeout; waiting for SSE terminal event", + ); + return; + } const errorMsg = normalizeTurnFailureError(response.error); this.finishForegroundTurn( { type: "turn_failed", provider: "opencode", error: errorMsg }, @@ -1611,6 +1671,17 @@ class OpenCodeAgentSession implements AgentSession { } }) .catch((err) => { + if (isOpenCodeHeadersTimeoutFailure(err)) { + this.logger.warn( + { + err, + commandName: slashCommand.commandName, + turnId, + }, + "OpenCode slash command hit a header timeout; waiting for SSE terminal event", + ); + return; + } this.finishForegroundTurn( { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) }, turnId, From d40e4b6f0d0a577ff3087a45f2095bd51dd99d84 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 09:57:05 +0700 Subject: [PATCH 27/33] =?UTF-8?q?feat:=20add=20=E2=8C=98L=20shortcut=20to?= =?UTF-8?q?=20focus=20message=20input=20with=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a muted "⌘L to focus" hint in the top-right of the message input when unfocused and empty on web. Registers Cmd+L (Mac) / Ctrl+L (non-Mac) bindings for the existing message-input.focus action. --- packages/app/src/components/message-input.tsx | 19 ++++++++++++++ .../app/src/keyboard/keyboard-shortcuts.ts | 26 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 2b5396493..c30e5342c 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -42,6 +42,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { Shortcut } from "@/components/ui/shortcut"; import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { formatShortcut } from "@/utils/format-shortcut"; +import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { markScrollInvestigationEvent, @@ -233,7 +235,9 @@ export const MessageInput = forwardRef(funct const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const queueKeys = useShortcutKeys("message-input-queue"); + const focusInputKeys = useShortcutKeys("focus-message-input"); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); + const [isInputFocused, setIsInputFocused] = useState(false); const rootRef = useRef(null); const inputWrapperRef = useRef(null); const textInputRef = useRef unknown }) | null>( @@ -997,10 +1001,12 @@ export const MessageInput = forwardRef(funct accessibilityLabel="Message agent..." onFocus={() => { isInputFocusedRef.current = true; + setIsInputFocused(true); onFocusChange?.(true); }} onBlur={() => { isInputFocusedRef.current = false; + setIsInputFocused(false); onFocusChange?.(false); }} style={[ @@ -1025,6 +1031,11 @@ export const MessageInput = forwardRef(funct autoFocus={isWeb && autoFocus} /> {inputScrollbar} + {isWeb && !isInputFocused && !value && focusInputKeys ? ( + + {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus + + ) : null} {/* Button row */} @@ -1270,6 +1281,14 @@ const styles = StyleSheet.create(((theme: any) => ({ textInputScrollWrapper: { position: "relative", }, + focusHintText: { + position: "absolute", + top: 0, + right: 0, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + opacity: 0.5, + }, textInput: { width: "100%", color: theme.colors.foreground, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index b356c8473..94ae55ac9 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -791,6 +791,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, // --- Message input --- + { + id: "message-input-focus-cmd-l-mac", + action: "message-input.action", + combo: "Cmd+L", + when: { mac: true, commandCenter: false }, + payload: { type: "message-input", kind: "focus" }, + help: { + id: "focus-message-input", + section: "agent-input", + label: "Focus message input", + keys: ["mod", "L"], + }, + }, + { + id: "message-input-focus-ctrl-l-non-mac", + action: "message-input.action", + combo: "Ctrl+L", + when: { mac: false, commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "focus" }, + help: { + id: "focus-message-input", + section: "agent-input", + label: "Focus message input", + keys: ["mod", "L"], + }, + }, { id: "message-input-voice-toggle-cmd-shift-d-mac", action: "message-input.action", From 3fe79535cc2d37c89709a875e19752701c75f435 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 10:33:42 +0700 Subject: [PATCH 28/33] =?UTF-8?q?fix:=20only=20show=20=E2=8C=98L=20focus?= =?UTF-8?q?=20hint=20when=20agent=20panel=20is=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/components/message-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index c30e5342c..fdf3492c8 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -1031,7 +1031,7 @@ export const MessageInput = forwardRef(funct autoFocus={isWeb && autoFocus} /> {inputScrollbar} - {isWeb && !isInputFocused && !value && focusInputKeys ? ( + {isWeb && isInputActive && !isInputFocused && !value && focusInputKeys ? ( {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus From fc96b260d10dd002824f8f08af1f4823d1c9bfb4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 10:36:36 +0700 Subject: [PATCH 29/33] refactor: rename isInputActive to isPaneFocused for clarity The prop represents whether the pane/panel is focused, not whether the text input is focused. Rename across all 5 files to match the caller names and reduce confusion. --- packages/app/src/components/agent-input-area.tsx | 14 +++++++------- packages/app/src/components/message-input.tsx | 10 +++++----- packages/app/src/panels/agent-panel.tsx | 2 +- .../app/src/screens/agent/draft-agent-screen.tsx | 2 +- .../workspace/workspace-draft-agent-tab.tsx | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 9bf29bc74..147df7dd7 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -63,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA interface AgentInputAreaProps { agentId: string; serverId: string; - isInputActive: boolean; + isPaneFocused: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; @@ -98,7 +98,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; export function AgentInputArea({ agentId, serverId, - isInputActive, + isPaneFocused, onSubmitMessage, isSubmitLoading = false, blurOnSubmit = false, @@ -421,7 +421,7 @@ export function AgentInputArea({ const handleKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { - if (!isInputActive) { + if (!isPaneFocused) { return false; } @@ -461,7 +461,7 @@ export function AgentInputArea({ return false; } }, - [isInputActive], + [isPaneFocused], ); useKeyboardActionHandler({ @@ -475,9 +475,9 @@ export function AgentInputArea({ "message-input.voice-toggle", "message-input.voice-mute-toggle", ], - enabled: isInputActive, + enabled: isPaneFocused, priority: isMessageInputFocused ? 200 : 100, - isActive: () => isInputActive, + isActive: () => isPaneFocused, handle: handleKeyboardAction, }); @@ -736,7 +736,7 @@ export function AgentInputArea({ autoFocus={autoFocus && isDesktopWebBreakpoint} autoFocusKey={`${serverId}:${agentId}`} disabled={isSubmitLoading} - isInputActive={isInputActive} + isPaneFocused={isPaneFocused} leftContent={leftContent} beforeVoiceContent={beforeVoiceContent} rightContent={rightContent} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index fdf3492c8..44ce35c52 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -77,8 +77,8 @@ export interface MessageInputProps { autoFocus?: boolean; autoFocusKey?: string; disabled?: boolean; - /** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */ - isInputActive?: boolean; + /** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */ + isPaneFocused?: boolean; /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ @@ -208,7 +208,7 @@ export const MessageInput = forwardRef(funct autoFocus = false, autoFocusKey, disabled = false, - isInputActive = true, + isPaneFocused = true, leftContent, beforeVoiceContent, rightContent, @@ -431,7 +431,7 @@ export const MessageInput = forwardRef(funct onError: handleDictationError, canStart: canStartDictation, canConfirm: canConfirmDictation, - autoStopWhenHidden: { isVisible: isInputActive }, + autoStopWhenHidden: { isVisible: isPaneFocused }, enableDuration: true, }); @@ -1031,7 +1031,7 @@ export const MessageInput = forwardRef(funct autoFocus={isWeb && autoFocus} /> {inputScrollbar} - {isWeb && isInputActive && !isInputFocused && !value && focusInputKeys ? ( + {isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? ( {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 1c2325663..ba909e776 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -762,7 +762,7 @@ function AgentPanelBody({ Date: Wed, 15 Apr 2026 10:40:24 +0700 Subject: [PATCH 30/33] docs: add local test suite execution rules --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2edfb88f8..9d897d32f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir - **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process. - **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. - **NEVER add auth checks to tests** — agent providers handle their own auth. +- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules: + - Run only the specific test file you changed: `npx vitest run --bail=1` + - Never run `npm run test` for an entire workspace unless explicitly asked. + - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file. + - Never re-run a test suite that another agent already ran and reported green — trust the result. + - For full suite verification, push to CI and check GitHub Actions instead. - **Always run typecheck after every change.** - **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it. - **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons: From 7a8f65805d62e7bd2e3c3db1299fb0d8bcdef01c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 11:47:49 +0700 Subject: [PATCH 31/33] refactor: rename allowedHosts to hostnames (#413) * refactor: rename allowedHosts to hostnames for DNS rebinding config The old name confused users who thought it was related to CORS. "hostnames" communicates that you're declaring the daemon's own hostnames, not granting access to external parties. Backward compatible: old config files (daemon.allowedHosts), env var (PASEO_ALLOWED_HOSTS), CLI flag (--allowed-hosts), and Nix option (allowedHosts) all still work as deprecated aliases. * fix(nix): update npmDepsHash to match current package-lock.json --- SECURITY.md | 2 +- docs/AD-HOC-DAEMON-TESTING.md | 2 +- docs/DATA_MODEL.md | 2 +- nix/module.nix | 16 ++++-- nix/package.nix | 2 +- packages/cli/src/cli.ts | 25 +++++++-- packages/cli/src/commands/daemon/index.ts | 25 +++++++-- .../cli/src/commands/daemon/local-daemon.ts | 7 ++- packages/cli/src/commands/daemon/restart.ts | 2 +- packages/cli/src/commands/daemon/start.ts | 18 ++++-- packages/cli/src/commands/onboard.ts | 26 ++++++--- .../src/server/agent/agent-mcp.e2e.test.ts | 8 +-- .../agent/agent-response-loop.e2e.test.ts | 6 +- .../server/src/server/allowed-hosts.test.ts | 45 --------------- .../server/src/server/bootstrap.smoke.test.ts | 4 +- packages/server/src/server/bootstrap.ts | 8 +-- packages/server/src/server/config.ts | 18 +++--- packages/server/src/server/hostnames.test.ts | 56 +++++++++++++++++++ .../server/{allowed-hosts.ts => hostnames.ts} | 26 ++++----- .../server/src/server/persisted-config.ts | 5 ++ .../src/server/test-utils/paseo-daemon.ts | 2 +- .../server/src/server/websocket-server.ts | 10 ++-- .../public/schemas/paseo.config.v1.json | 15 +++++ .../website/src/routes/docs/configuration.tsx | 20 +++++-- packages/website/src/routes/docs/security.tsx | 6 +- 25 files changed, 223 insertions(+), 133 deletions(-) delete mode 100644 packages/server/src/server/allowed-hosts.test.ts create mode 100644 packages/server/src/server/hostnames.test.ts rename packages/server/src/server/{allowed-hosts.ts => hostnames.ts} (72%) diff --git a/SECURITY.md b/SECURITY.md index 6ad5fdeb2..d582f9cfd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding). -Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected. +Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected. ## Agent authentication diff --git a/docs/AD-HOC-DAEMON-TESTING.md b/docs/AD-HOC-DAEMON-TESTING.md index 30c0d2e65..6918bb727 100644 --- a/docs/AD-HOC-DAEMON-TESTING.md +++ b/docs/AD-HOC-DAEMON-TESTING.md @@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon( listen: "127.0.0.1:0", // OS picks a free port paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 86fb7306a..f839afb51 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`. version: 1, daemon: { listen: "127.0.0.1:6767", - allowedHosts: true | string[], + hostnames: true | string[], mcp: { enabled: boolean }, cors: { allowedOrigins: string[] }, relay: { enabled: boolean, endpoint: string, publicEndpoint: string } diff --git a/nix/module.nix b/nix/module.nix index 72a387614..022fc9eb8 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -9,6 +9,10 @@ let cfg = config.services.paseo; in { + imports = [ + (lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ]) + ]; + options.services.paseo = { enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents"; @@ -58,12 +62,12 @@ in description = "Whether to open the firewall for the Paseo daemon port."; }; - allowedHosts = lib.mkOption { + hostnames = lib.mkOption { type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str); default = [ ]; example = [ ".example.com" "myhost.local" ]; description = '' - Hosts allowed to connect to the Paseo daemon (DNS rebinding protection). + Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection). Localhost and IP addresses are always allowed by default. Use a leading dot to match a domain and all its subdomains @@ -141,10 +145,10 @@ in "/run/wrappers/bin" "/nix/var/nix/profiles/default/bin" ]); - } // lib.optionalAttrs (cfg.allowedHosts == true) { - PASEO_ALLOWED_HOSTS = "true"; - } // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) { - PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts; + } // lib.optionalAttrs (cfg.hostnames == true) { + PASEO_HOSTNAMES = "true"; + } // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) { + PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames; } // cfg.environment; serviceConfig = { diff --git a/nix/package.nix b/nix/package.nix index 5eef96dc3..dd0909a3f 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-1mnfVYAAGP6x9ywFHEQ55q4i/C9uuZ2foU1ytacRMrE="; + npmDepsHash = "sha256-NBB8+DNyodnREhVEiNMLADd+4MMK4lssRuzlwvvPjk8="; # 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). diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5da470821..cb4baceec 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { createRequire } from "node:module"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; @@ -124,10 +124,27 @@ export function createCli(): Command { .option("--no-relay", "Disable relay on restarted daemon") .option("--no-mcp", "Disable Agent MCP on restarted daemon") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(withOutput(runDaemonRestartCommand)); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action( + withOutput((...args) => { + const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; + return runDaemonRestartCommand( + { + ...options, + hostnames: + typeof options.hostnames === "string" + ? options.hostnames + : typeof options.allowedHosts === "string" + ? options.allowedHosts + : undefined, + }, + command, + ); + }), + ); // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()); diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 5cb4f5b80..d9ef61297 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { startCommand } from "./start.js"; import { runStatusCommand } from "./status.js"; import { runStopCommand } from "./stop.js"; @@ -36,10 +36,27 @@ export function createDaemonCommand(): Command { .option("--no-mcp", "Disable Agent MCP on restarted daemon") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(withOutput(runRestartCommand)); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action( + withOutput((...args) => { + const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; + return runRestartCommand( + { + ...options, + hostnames: + typeof options.hostnames === "string" + ? options.hostnames + : typeof options.allowedHosts === "string" + ? options.allowedHosts + : undefined, + }, + command, + ); + }), + ); return daemon; } diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 7d00a3271..812a48ae4 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -13,7 +13,7 @@ export interface DaemonStartOptions { relay?: boolean; mcp?: boolean; injectMcp?: boolean; - allowedHosts?: string; + hostnames?: string; } export interface LocalDaemonPidInfo { @@ -113,8 +113,8 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { } else if (options.port) { childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`; } - if (options.allowedHosts) { - childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts; + if (options.hostnames) { + childEnv.PASEO_HOSTNAMES = options.hostnames; } return childEnv; } @@ -322,6 +322,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD ...envWithHome(options.home), // Status should reflect local persisted config + pid file, not inherited daemon env overrides. PASEO_LISTEN: undefined, + PASEO_HOSTNAMES: undefined, PASEO_ALLOWED_HOSTS: undefined, }; const home = resolvePaseoHome(env); diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 82df597f5..741057f6a 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -61,7 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions { relay: typeof options.relay === "boolean" ? options.relay : undefined, mcp: typeof options.mcp === "boolean" ? options.mcp : undefined, injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined, - allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined, + hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined, }; if (startOptions.listen && startOptions.port) { diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 1e5cd2264..7a3e5991e 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import chalk from "chalk"; import { startLocalDaemonForeground, @@ -9,6 +9,10 @@ import { getErrorMessage } from "../../utils/errors.js"; export type { DaemonStartOptions as StartOptions } from "./local-daemon.js"; +type RawStartCommandOptions = StartOptions & { + allowedHosts?: string; +}; + export function startCommand(): Command { return new Command("start") .description("Start the local Paseo daemon") @@ -20,11 +24,15 @@ export function startCommand(): Command { .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(async (options: StartOptions) => { - await runStart(options); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action(async (options: RawStartCommandOptions) => { + await runStart({ + ...options, + hostnames: options.hostnames ?? options.allowedHosts, + }); }); } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index a9d4168d3..480e857a7 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,5 +1,5 @@ import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import { writeFileSync } from "node:fs"; import path from "node:path"; import { @@ -24,6 +24,10 @@ interface OnboardOptions extends DaemonStartOptions { voice?: "ask" | "enable" | "disable"; } +type RawOnboardOptions = OnboardOptions & { + allowedHosts?: string; +}; + type OnboardPersistedConfig = PersistedConfig & { features?: PersistedConfig["features"] & { dictation?: PersistedConfig["features"] extends { dictation?: infer T } @@ -64,7 +68,7 @@ function parseTimeoutMs(raw: string | undefined): number { return Math.ceil(seconds * 1000); } -function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides { +function toCliOverrides(options: OnboardOptions): CliConfigOverrides { const cliOverrides: CliConfigOverrides = {}; if (options.listen) { @@ -77,9 +81,9 @@ function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides { cliOverrides.relayEnabled = false; } - if (options.allowedHosts) { - const raw = options.allowedHosts.trim(); - cliOverrides.allowedHosts = + if (options.hostnames) { + const raw = options.hostnames.trim(); + cliOverrides.hostnames = raw.toLowerCase() === "true" ? true : raw @@ -297,13 +301,17 @@ export function onboardCommand(): Command { .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) + .addOption(new Option("--allowed-hosts ").hideHelp()) .option("--timeout ", "Max time to wait for daemon readiness (default: 600)") .option("--voice ", "Voice setup mode: ask, enable, disable", "ask") - .action(async (options: OnboardOptions) => { - await runOnboard(options); + .action(async (options: RawOnboardOptions) => { + await runOnboard({ + ...options, + hostnames: options.hostnames ?? options.allowedHosts, + }); }); } diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index ad95ce1b6..16ff84152 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, @@ -192,7 +192,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, @@ -216,7 +216,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${disabledPort}`, paseoHome: disabledPaseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, mcpInjectIntoAgents: false, staticDir: disabledStaticDir, @@ -307,7 +307,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts index da63dae7f..8b5c3aa71 100644 --- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts @@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise(); const createAgentMcpTransport = async (callerAgentId?: string) => { @@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise { @@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise { - it("allows localhost by default", () => { - expect(isHostAllowed("localhost:6767", undefined)).toBe(true); - }); - - it("allows subdomains of .localhost by default", () => { - expect(isHostAllowed("foo.localhost:6767", undefined)).toBe(true); - }); - - it("allows IP addresses by default", () => { - expect(isHostAllowed("127.0.0.1:6767", undefined)).toBe(true); - expect(isHostAllowed("[::1]:6767", undefined)).toBe(true); - }); - - it("rejects non-default hosts when no allowlist is provided", () => { - expect(isHostAllowed("evil.com:6767", undefined)).toBe(false); - }); - - it("allows any host when set to true", () => { - expect(isHostAllowed("evil.com:6767", true)).toBe(true); - }); - - it("supports leading-dot patterns", () => { - const allowed = [".example.com"]; - expect(isHostAllowed("example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("foo.example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("foo.bar.example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("notexample.com:6767", allowed)).toBe(false); - }); - - it("merges arrays (append + de-dupe) and short-circuits on true", () => { - expect(mergeAllowedHosts([["a"], ["a", "b"]])).toEqual(["a", "b"]); - expect(mergeAllowedHosts([["a"], true, ["b"]])).toBe(true); - }); - - it("parses env var values", () => { - expect(parseAllowedHostsEnv(undefined)).toBeUndefined(); - expect(parseAllowedHostsEnv("")).toBeUndefined(); - expect(parseAllowedHostsEnv("true")).toBe(true); - expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]); - }); -}); diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index ccc42eccf..9d1096aea 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => { listen: "127.0.0.1:0", paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, @@ -163,7 +163,7 @@ describe("paseo daemon bootstrap", () => { listen: socketPath, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b224a674c..f3eedfcaa 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -119,7 +119,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; -import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; +import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js"; type AgentMcpTransportMap = Map; @@ -162,7 +162,7 @@ export type PaseoDaemonConfig = { listen: string; paseoHome: string; corsAllowedOrigins: string[]; - allowedHosts?: AllowedHostsConfig; + hostnames?: HostnamesConfig; mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; staticDir: string; @@ -231,7 +231,7 @@ export async function createPaseoDaemon( if (listenTarget.type === "tcp") { app.use((req, res, next) => { const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined; - if (!isHostAllowed(hostHeader, config.allowedHosts)) { + if (!isHostnameAllowed(hostHeader, config.hostnames)) { res.status(403).json({ error: "Invalid Host header" }); return; } @@ -604,7 +604,7 @@ export async function createPaseoDaemon( config.paseoHome, daemonConfigStore, mcpBaseUrl, - { allowedOrigins, allowedHosts: config.allowedHosts }, + { allowedOrigins, hostnames: config.hostnames }, speechService, terminalManager, { diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 02fc0cf8e..3115e63d7 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -11,11 +11,7 @@ import type { import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "./agent/provider-manifest.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; -import { - mergeAllowedHosts, - parseAllowedHostsEnv, - type AllowedHostsConfig, -} from "./allowed-hosts.js"; +import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; const DEFAULT_PORT = 6767; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; @@ -42,7 +38,7 @@ export type CliConfigOverrides = Partial<{ relayEnabled: boolean; mcpEnabled: boolean; mcpInjectIntoAgents: boolean; - allowedHosts: AllowedHostsConfig; + hostnames: HostnamesConfig; }>; const OptionalVoiceLlmProviderSchema = z @@ -133,10 +129,10 @@ export function loadConfig( const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? []; - const allowedHosts = mergeAllowedHosts([ - persisted.daemon?.allowedHosts, - parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS), - options?.cli?.allowedHosts, + const hostnames = mergeHostnames([ + persisted.daemon?.hostnames, + parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), + options?.cli?.hostnames, ]); const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true; @@ -181,7 +177,7 @@ export function loadConfig( corsAllowedOrigins: Array.from( new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)), ), - allowedHosts, + hostnames, mcpEnabled, mcpInjectIntoAgents, mcpDebug: env.MCP_DEBUG === "1", diff --git a/packages/server/src/server/hostnames.test.ts b/packages/server/src/server/hostnames.test.ts new file mode 100644 index 000000000..88778f519 --- /dev/null +++ b/packages/server/src/server/hostnames.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { PersistedConfigSchema } from "./persisted-config.js"; +import { isHostnameAllowed, mergeHostnames, parseHostnamesEnv } from "./hostnames.js"; + +describe("hostnames (vite-style)", () => { + it("allows localhost by default", () => { + expect(isHostnameAllowed("localhost:6767", undefined)).toBe(true); + }); + + it("allows subdomains of .localhost by default", () => { + expect(isHostnameAllowed("foo.localhost:6767", undefined)).toBe(true); + }); + + it("allows IP addresses by default", () => { + expect(isHostnameAllowed("127.0.0.1:6767", undefined)).toBe(true); + expect(isHostnameAllowed("[::1]:6767", undefined)).toBe(true); + }); + + it("rejects non-default hosts when no allowlist is provided", () => { + expect(isHostnameAllowed("evil.com:6767", undefined)).toBe(false); + }); + + it("allows any host when set to true", () => { + expect(isHostnameAllowed("evil.com:6767", true)).toBe(true); + }); + + it("supports leading-dot patterns", () => { + const hostnames = [".example.com"]; + expect(isHostnameAllowed("example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("foo.example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("foo.bar.example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("notexample.com:6767", hostnames)).toBe(false); + }); + + it("merges arrays (append + de-dupe) and short-circuits on true", () => { + expect(mergeHostnames([["a"], ["a", "b"]])).toEqual(["a", "b"]); + expect(mergeHostnames([["a"], true, ["b"]])).toBe(true); + }); + + it("parses env var values", () => { + expect(parseHostnamesEnv(undefined)).toBeUndefined(); + expect(parseHostnamesEnv("")).toBeUndefined(); + expect(parseHostnamesEnv("true")).toBe(true); + expect(parseHostnamesEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]); + }); + + it("normalizes persisted allowedHosts into hostnames for backward compatibility", () => { + const parsed = PersistedConfigSchema.parse({ + daemon: { + allowedHosts: [".example.com"], + }, + }); + + expect(parsed.daemon?.hostnames).toEqual([".example.com"]); + }); +}); diff --git a/packages/server/src/server/allowed-hosts.ts b/packages/server/src/server/hostnames.ts similarity index 72% rename from packages/server/src/server/allowed-hosts.ts rename to packages/server/src/server/hostnames.ts index 6186b1fe3..0253305b2 100644 --- a/packages/server/src/server/allowed-hosts.ts +++ b/packages/server/src/server/hostnames.ts @@ -1,6 +1,6 @@ import net from "node:net"; -export type AllowedHostsConfig = true | string[] | undefined; +export type HostnamesConfig = true | string[] | undefined; function normalizeHostname(hostname: string): string { return hostname.trim().toLowerCase(); @@ -25,7 +25,7 @@ function parseHostnameFromHostHeader(hostHeader: string): string | null { return normalizeHostname(trimmed.slice(0, colonIndex)); } -function matchesAllowedHostPattern(hostname: string, pattern: string): boolean { +function matchesHostnamePattern(hostname: string, pattern: string): boolean { const normalizedPattern = normalizeHostname(pattern); if (!normalizedPattern) return false; @@ -47,33 +47,33 @@ function isDefaultAllowedHostname(hostname: string): boolean { } /** - * Vite-style allowed hosts check, adapted to raw Host headers. + * Vite-style hostname allowlist check, adapted to raw Host headers. * * Semantics: - * - `allowedHosts === true` => allow any host. - * - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs. - * - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults. + * - `hostnames === true` => allow any host. + * - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs. + * - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults. */ -export function isHostAllowed( +export function isHostnameAllowed( hostHeader: string | undefined, - allowedHosts: AllowedHostsConfig, + hostnames: HostnamesConfig, ): boolean { const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null; if (!hostname) return false; - if (allowedHosts === true) return true; + if (hostnames === true) return true; // Defaults are always allowed. if (isDefaultAllowedHostname(hostname)) return true; - const patterns = allowedHosts ?? []; + const patterns = hostnames ?? []; for (const pattern of patterns) { - if (matchesAllowedHostPattern(hostname, pattern)) return true; + if (matchesHostnamePattern(hostname, pattern)) return true; } return false; } -export function mergeAllowedHosts(values: Array): AllowedHostsConfig { +export function mergeHostnames(values: Array): HostnamesConfig { let merged: string[] = []; for (const value of values) { if (value === true) return true; @@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array): AllowedHos return deduped; } -export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig { +export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig { if (!raw) return undefined; const trimmed = raw.trim(); if (!trimmed) return undefined; diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 5fd0483a2..362369932 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -228,6 +228,7 @@ export const PersistedConfigSchema = z daemon: z .object({ listen: z.string().optional(), + hostnames: z.union([z.literal(true), z.array(z.string())]).optional(), allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(), mcp: z .object({ @@ -252,6 +253,10 @@ export const PersistedConfigSchema = z .optional(), }) .strict() + .transform(({ allowedHosts, ...daemon }) => { + const hostnames = daemon.hostnames ?? allowedHosts; + return hostnames === undefined ? daemon : { ...daemon, hostnames }; + }) .optional(), app: z diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 902dcb0f8..c34fa0a3d 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -85,7 +85,7 @@ export async function createTestPaseoDaemon( listen: `${listenHost}:0`, paseoHome, corsAllowedOrigins: options.corsAllowedOrigins ?? [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 637a1902b..5e1a10e73 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -23,8 +23,8 @@ import { wrapSessionMessage, } from "./messages.js"; import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js"; -import type { AllowedHostsConfig } from "./allowed-hosts.js"; -import { isHostAllowed } from "./allowed-hosts.js"; +import type { HostnamesConfig } from "./hostnames.js"; +import { isHostnameAllowed } from "./hostnames.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { @@ -61,7 +61,7 @@ type PendingConnection = { type WebSocketServerConfig = { allowedOrigins: Set; - allowedHosts?: AllowedHostsConfig; + hostnames?: HostnamesConfig; }; type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; @@ -433,7 +433,7 @@ export class VoiceAssistantWebSocketServer { this.broadcastAgentAttention(params); }); - const { allowedOrigins, allowedHosts } = wsConfig; + const { allowedOrigins, hostnames } = wsConfig; this.wss = new WebSocketServer({ server, path: "/ws", @@ -441,7 +441,7 @@ export class VoiceAssistantWebSocketServer { const requestMetadata = extractSocketRequestMetadata(req); const origin = requestMetadata.origin; const requestHost = requestMetadata.host ?? null; - if (requestHost && !isHostAllowed(requestHost, allowedHosts)) { + if (requestHost && !isHostnameAllowed(requestHost, hostnames)) { this.incrementRuntimeCounter("hostRejected"); this.logger.warn( { ...requestMetadata, host: requestHost }, diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index a4e12119a..c9b74032a 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -14,7 +14,22 @@ "listen": { "type": "string" }, + "hostnames": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "allowedHosts": { + "description": "Deprecated: use hostnames instead", "anyOf": [ { "type": "boolean", diff --git a/packages/website/src/routes/docs/configuration.tsx b/packages/website/src/routes/docs/configuration.tsx index 1aac1f2a6..368ae6157 100644 --- a/packages/website/src/routes/docs/configuration.tsx +++ b/packages/website/src/routes/docs/configuration.tsx @@ -51,8 +51,7 @@ function Configuration() {
  • CLI flags
  • - Lists append across sources (for example, allowedHosts{" "} - and + Lists append across sources (for example, hostnames and cors.allowedOrigins).

    @@ -60,7 +59,7 @@ function Configuration() {

    Example

    - Minimal example that configures listening address, host allowlist, provider keys, and MCP: + Minimal example that configures listening address, hostnames, provider keys, and MCP:

               {`{
    @@ -71,11 +70,16 @@ function Configuration() {
       },
       "daemon": {
         "listen": "127.0.0.1:6767",
    -    "allowedHosts": ["localhost", ".localhost"],
    +    "hostnames": ["localhost", ".localhost"],
         "mcp": { "enabled": true }
       }
     }`}
             
    +

    + daemon.hostnames is the primary field. The old{" "} + daemon.allowedHosts name still works as a deprecated + alias for backward compatibility. +

    @@ -229,8 +233,12 @@ docker run --rm -i \\ daemon.listen
  • - PASEO_ALLOWED_HOSTS — override/extend{" "} - daemon.allowedHosts + PASEO_HOSTNAMES — override/extend{" "} + daemon.hostnames +
  • +
  • + PASEO_ALLOWED_HOSTS — deprecated alias for{" "} + PASEO_HOSTNAMES
  • PASEO_LOG_CONSOLE_LEVEL — override{" "} diff --git a/packages/website/src/routes/docs/security.tsx b/packages/website/src/routes/docs/security.tsx index d148331cf..5b0d03f78 100644 --- a/packages/website/src/routes/docs/security.tsx +++ b/packages/website/src/routes/docs/security.tsx @@ -185,7 +185,7 @@ function Security() { 100.x.y.z:6767)
  • - Add your Tailscale hostname to allowedHosts and{" "} + Add your Tailscale hostname to hostnames and{" "} cors.allowedOrigins
  • @@ -199,7 +199,7 @@ function Security() { daemon reachable on all network interfaces, including public Wi-Fi and local networks. This can expose your daemon to unauthorized access. If you must bind to all interfaces, ensure you have proper firewall rules and review your{" "} - allowedHosts configuration. + hostnames configuration.
  • @@ -216,7 +216,7 @@ function Security() { on incoming requests. Requests with unrecognized hosts are rejected.

    - Configure via daemon.allowedHosts in{" "} + Configure via daemon.hostnames in{" "} config.json:

      From c1df523a77f2384043ca9a8c21817a60e2edb1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Wed, 15 Apr 2026 01:14:47 -0400 Subject: [PATCH 32/33] fix: update lockfile signatures and Nix hash (#412) Co-authored-by: github-actions[bot] From 918949c7fa421d1a7b764c1f153d0b299b597b59 Mon Sep 17 00:00:00 2001 From: Li Mu Zhi Date: Wed, 15 Apr 2026 13:24:14 +0800 Subject: [PATCH 33/33] fix: file preview shows stale content when re-opening a file (#411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: file preview shows stale content when re-opening a file The file content query inherits `refetchOnMount: false` and `gcTime: Infinity` from the global QueryClient defaults, so re-opening a previously viewed file never re-fetches its content. Set `staleTime: 0` and `refetchOnMount: true` on the file preview query so it always fetches the latest content when the component mounts. Fixes #351 * fix: keep original staleTime, only add refetchOnMount staleTime: 5_000 is reasonable — avoids redundant fetches when quickly toggling the same file. The actual fix only needs refetchOnMount: true to override the global default of false. --------- Co-authored-by: muzhi1991 --- packages/app/src/components/file-pane.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index d65a34cbe..e11419e57 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -264,6 +264,7 @@ export function FilePane({ return { file: payload.file ?? null, error: payload.error ?? null }; }, staleTime: 5_000, + refetchOnMount: true, }); return (