From 8047394154eb78974184053a420b219222d9cbbf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 29 Mar 2026 23:28:00 +0700 Subject: [PATCH 01/15] docs: add 0.1.37 changelog entry --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d763b1bd..40c9ddf64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.37 - 2026-03-29 + +### Added +- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design. +- Desktop file logging with electron-log for easier debugging of daemon and app issues. + +### Fixed +- Fixed broken PATH propagation and Claude binary resolution on Windows. +- Dictation errors now show a visible toast instead of failing silently. + ## 0.1.36 - 2026-03-27 ### Fixed From 352a4e793ae3c797b5afa9d8d34b268af3922cb8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 08:48:06 +0700 Subject: [PATCH 02/15] feat(website): redeploy on new GitHub releases The website fetches the latest release version at build time, so it needs to redeploy when a new release is published to pick up updated download links. --- .github/workflows/deploy-website.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index c2c8b1bd8..64c04b83b 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -9,6 +9,8 @@ on: - 'package-lock.json' - 'patches/**' - '.github/workflows/deploy-website.yml' + release: + types: [published, edited] workflow_dispatch: jobs: From e75873480796517b6bf2c7592cc2fa6bde2f31a2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 12:31:42 +0700 Subject: [PATCH 03/15] feat(website): update marketing copy and section styling Rework multi-provider and self-hosted sections with clearer copy. Scale up self-hosted diagram cards to match multi-provider sizing. --- .../website/src/components/landing-page.tsx | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 636b2f02f..9cea3e24c 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -300,8 +300,8 @@ function MultiProviderSection() { return (
{providers.map((p) => ( @@ -320,10 +320,10 @@ function MultiProviderSection() { function SelfHostedDiagram() { const clients = [ - { name: "Desktop", icon: }, - { name: "Web", icon: }, - { name: "Mobile", icon: }, - { name: "CLI", icon: }, + { name: "Desktop", icon: }, + { name: "Web", icon: }, + { name: "Mobile", icon: }, + { name: "CLI", icon: }, ]; const hosts = ["MacBook Pro", "Hetzner VM", "Dev server"]; const containerRef = React.useRef(null); @@ -376,9 +376,9 @@ function SelfHostedDiagram() {
{clients.map((c) => ( -
- {c.icon} - {c.name} +
+ {c.icon} + {c.name}
))}
@@ -391,14 +391,14 @@ function SelfHostedDiagram() {
{hosts.map((h) => ( -
- +
+ - - {h} + + {h}
))}
@@ -419,10 +419,10 @@ function SelfHostedDiagram() {
{ clientRefs.current[i] = el; }} - className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm backdrop-blur-sm" + className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 backdrop-blur-sm" > - {c.icon} - {c.name} + {c.icon} + {c.name}
))}
@@ -431,10 +431,10 @@ function SelfHostedDiagram() {
{/* Center label */} -
-

E2E Encrypted Relay

-

or

-

Direct Connection

+
+

E2E Encrypted Relay

+

or

+

Direct Connection

{/* Spacer */} @@ -446,15 +446,15 @@ function SelfHostedDiagram() {
{ hostRefs.current[i] = el; }} - className="flex items-center gap-3 rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm backdrop-blur-sm" + className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 backdrop-blur-sm" > - + - - {h} + + {h}
))}
@@ -466,8 +466,8 @@ function SelfHostedDiagram() { function SelfHostedSection() { return ( From 8d0a9f922455fba4044fe51fb1317020183800f5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 12:37:07 +0700 Subject: [PATCH 04/15] fix(server): fix daemon startup race and reduce log bloat The desktop app could time out connecting to the daemon on first launch because the PID file advertised a listen address before the HTTP server was actually listening. The supervisor now writes the PID lock with listen: null and updates it only after the worker sends a paseo:ready IPC message confirming the server is listening. - Rename daemon-runner.ts to supervisor-entrypoint.ts - PID lock acquired with listen: null, updated via paseo:ready IPC - Worker sends paseo:ready after httpServer.listen() resolves - Desktop polls until listen is non-null before returning to the app - Remove PASEO_PID_LOCK_MODE and external lock mode - Remove unnecessary env overrides (PASEO_HOME, PASEO_CORS_ORIGINS) from desktop daemon spawn - Reduce trace log bloat: inbound/outbound WebSocket messages now log only message type and payload size instead of full payloads - Supervisor restarts worker on SIGKILL (covers OOM) --- nix/package.nix | 2 +- .../app/src/desktop/daemon/desktop-daemon.ts | 4 +- packages/app/src/runtime/host-runtime.ts | 2 +- .../cli/src/commands/daemon/local-daemon.ts | 9 +++- .../tests/22-daemon-stop-supervisor.test.ts | 37 +++++++------ .../tests/23-daemon-sigint-supervisor.test.ts | 32 ++++++----- .../tests/24-daemon-stop-ownership.test.ts | 2 +- .../25-daemon-restart-supervisor.test.ts | 28 +++++----- .../26-daemon-restart-unsupervised.test.ts | 5 -- packages/desktop/src/daemon/daemon-manager.ts | 53 +++++-------------- packages/desktop/src/daemon/runtime-paths.ts | 11 ++-- packages/server/scripts/dev-runner.ts | 2 +- .../server/scripts/supervision-parity.test.ts | 5 +- ...mon-runner.ts => supervisor-entrypoint.ts} | 19 ++++--- packages/server/scripts/supervisor.ts | 28 +++++++++- packages/server/src/server/bootstrap.ts | 37 ++++--------- packages/server/src/server/index.ts | 4 -- packages/server/src/server/pid-lock.test.ts | 18 +++++-- packages/server/src/server/pid-lock.ts | 35 +++++++++++- packages/server/src/server/session.ts | 10 +++- packages/server/tsconfig.scripts.json | 2 +- 21 files changed, 198 insertions(+), 147 deletions(-) rename packages/server/scripts/{daemon-runner.ts => supervisor-entrypoint.ts} (91%) diff --git a/nix/package.nix b/nix/package.nix index 6c26f2f2a..3ce7d4a09 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -114,7 +114,7 @@ buildNpmPackage rec { fi done - # Copy server scripts (daemon-runner, supervisor) needed by CLI + # Copy server scripts (including supervisor-entrypoint) needed by CLI if [ -d packages/server/dist/scripts ]; then mkdir -p $out/lib/paseo/packages/server/dist/scripts cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/ diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index a4186ac04..ec17b870a 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -6,7 +6,7 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; export type DesktopDaemonStatus = { serverId: string; status: DesktopDaemonState; - listen: string; + listen: string | null; hostname: string | null; pid: number | null; home: string; @@ -82,7 +82,7 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus { return { serverId: toStringOrNull(raw.serverId) ?? "", status: parseDesktopDaemonState(raw.status), - listen: toStringOrNull(raw.listen) ?? "", + listen: toStringOrNull(raw.listen), hostname: toStringOrNull(raw.hostname), pid: toNumberOrNull(raw.pid), home: toStringOrNull(raw.home) ?? "", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 5c81b1c17..74c3eb08c 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1172,7 +1172,7 @@ export class HostRuntimeStore { async bootstrapDesktop(): Promise { try { const daemon = await startDesktopDaemon(); - const listenAddress = daemon.listen.trim(); + const listenAddress = daemon.listen?.trim() ?? ""; const serverId = daemon.serverId.trim(); if (!listenAddress) { return { diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index f2c92ad1d..c2e39e513 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -124,11 +124,16 @@ function resolveDaemonRunnerEntry(): string { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string }; if (packageJson.name === "@getpaseo/server") { - const distRunner = path.join(currentDir, "dist", "scripts", "daemon-runner.js"); + const distRunner = path.join( + currentDir, + "dist", + "scripts", + "supervisor-entrypoint.js", + ); if (existsSync(distRunner)) { return distRunner; } - return path.join(currentDir, "scripts", "daemon-runner.ts"); + return path.join(currentDir, "scripts", "supervisor-entrypoint.ts"); } } catch { // Continue searching up if package.json exists but is invalid. diff --git a/packages/cli/tests/22-daemon-stop-supervisor.test.ts b/packages/cli/tests/22-daemon-stop-supervisor.test.ts index 2ef8a2d39..c706a8ea8 100644 --- a/packages/cli/tests/22-daemon-stop-supervisor.test.ts +++ b/packages/cli/tests/22-daemon-stop-supervisor.test.ts @@ -2,7 +2,7 @@ /** * Regression: `paseo daemon stop` must stop supervised dev daemons - * without allowing daemon-runner to respawn a new worker process. + * without allowing the supervisor entrypoint to respawn a new worker process. */ import assert from "node:assert"; @@ -120,20 +120,24 @@ let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; try { - console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME"); + console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); - supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], { - cwd: cliRoot, - env: { - ...process.env, - ...testEnv, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `127.0.0.1:${port}`, - PASEO_RELAY_ENABLED: "false", - CI: "true", + supervisorProcess = spawn( + "npx", + ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"], + { + cwd: cliRoot, + env: { + ...process.env, + ...testEnv, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + stdio: ["ignore", "pipe", "pipe"], }, - stdio: ["ignore", "pipe", "pipe"], - }); + ); supervisorProcess.stdout?.on("data", (chunk) => { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); @@ -161,8 +165,9 @@ try { const command = readProcessCommand(daemonPid); assert(command !== null, "pid lock pid should resolve to a running process command"); assert( - command.includes("daemon-runner.ts") || command.includes("daemon-runner.js"), - `pid lock pid should be daemon-runner process, got: ${command}`, + command.includes("supervisor-entrypoint.ts") || + command.includes("supervisor-entrypoint.js"), + `pid lock pid should be supervisor-entrypoint process, got: ${command}`, ); console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`); @@ -186,7 +191,7 @@ try { await waitFor( () => !isProcessRunning(supervisorProcess!.pid ?? -1), 15000, - "daemon-runner supervisor remained running after stop", + "supervisor-entrypoint process remained running after stop", ); } diff --git a/packages/cli/tests/23-daemon-sigint-supervisor.test.ts b/packages/cli/tests/23-daemon-sigint-supervisor.test.ts index eedd2154f..b7cbffb7c 100644 --- a/packages/cli/tests/23-daemon-sigint-supervisor.test.ts +++ b/packages/cli/tests/23-daemon-sigint-supervisor.test.ts @@ -1,7 +1,7 @@ #!/usr/bin/env npx tsx /** - * Regression: a single SIGINT sent to a supervised daemon-runner must allow + * Regression: a single SIGINT sent to the supervised supervisor entrypoint must allow * graceful daemon lifecycle shutdown to complete (no early forced exit path). */ @@ -131,21 +131,25 @@ let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; try { - console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME"); + console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); - supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], { - cwd: cliRoot, - env: { - ...process.env, - ...testEnv, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `127.0.0.1:${port}`, - PASEO_RELAY_ENABLED: "false", - CI: "true", + supervisorProcess = spawn( + "npx", + ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"], + { + cwd: cliRoot, + env: { + ...process.env, + ...testEnv, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - }); + ); supervisorProcess.stdout?.on("data", (chunk) => { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); diff --git a/packages/cli/tests/24-daemon-stop-ownership.test.ts b/packages/cli/tests/24-daemon-stop-ownership.test.ts index 53d783f51..966f804ff 100644 --- a/packages/cli/tests/24-daemon-stop-ownership.test.ts +++ b/packages/cli/tests/24-daemon-stop-ownership.test.ts @@ -62,7 +62,7 @@ try { "-e", // Keep the process alive long enough for stop command assertions. "setInterval(() => {}, 1000)", - "daemon-runner.ts", + "supervisor-entrypoint.ts", ], { env: { diff --git a/packages/cli/tests/25-daemon-restart-supervisor.test.ts b/packages/cli/tests/25-daemon-restart-supervisor.test.ts index 6187d71ae..c1361dca4 100644 --- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts +++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts @@ -122,20 +122,24 @@ let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; try { - console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME"); + console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); - supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], { - cwd: cliRoot, - env: { - ...process.env, - ...testEnv, - PASEO_HOME: paseoHome, - PASEO_LISTEN: host, - PASEO_RELAY_ENABLED: "false", - CI: "true", + supervisorProcess = spawn( + "npx", + ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"], + { + cwd: cliRoot, + env: { + ...process.env, + ...testEnv, + PASEO_HOME: paseoHome, + PASEO_LISTEN: host, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + stdio: ["ignore", "pipe", "pipe"], }, - stdio: ["ignore", "pipe", "pipe"], - }); + ); supervisorProcess.stdout?.on("data", (chunk) => { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); diff --git a/packages/cli/tests/26-daemon-restart-unsupervised.test.ts b/packages/cli/tests/26-daemon-restart-unsupervised.test.ts index e000f5265..172f1f752 100644 --- a/packages/cli/tests/26-daemon-restart-unsupervised.test.ts +++ b/packages/cli/tests/26-daemon-restart-unsupervised.test.ts @@ -134,11 +134,6 @@ try { env: { ...process.env, ...testEnv, - // This test validates direct unsupervised worker ownership semantics. - // Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external, - // which would delegate lock ownership away from this process and make - // daemon status checks fail to observe a running owner PID. - PASEO_PID_LOCK_MODE: "self", PASEO_HOME: paseoHome, PASEO_LISTEN: host, PASEO_RELAY_ENABLED: "false", diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 4df565e19..b807c08e7 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain } from "electron"; import log from "electron-log/main"; -import { loadConfig, resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server"; +import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -27,14 +27,13 @@ const STARTUP_POLL_MAX_ATTEMPTS = 150; const STOP_TIMEOUT_MS = 15_000; const KILL_TIMEOUT_MS = 3_000; const DETACHED_STARTUP_GRACE_MS = 1200; -const DEFAULT_ELECTRON_DEV_SERVER_URL = "http://localhost:8081"; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; type DesktopDaemonStatus = { serverId: string; status: DesktopDaemonState; - listen: string; + listen: string | null; hostname: string | null; pid: number | null; home: string; @@ -148,30 +147,6 @@ function logDesktopDaemonLifecycle(message: string, details?: Record value.trim()) - .filter((value) => value.length > 0), - ); - - const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL; - try { - const parsed = new URL(devServerUrl); - origins.add(parsed.origin); - - if (parsed.hostname === "localhost") { - origins.add(`${parsed.protocol}//127.0.0.1${parsed.port ? `:${parsed.port}` : ""}`); - } else if (parsed.hostname === "127.0.0.1") { - origins.add(`${parsed.protocol}//localhost${parsed.port ? `:${parsed.port}` : ""}`); - } - } catch { - // Ignore malformed dev server URLs and preserve any explicit env configuration. - } - - return origins.size > 0 ? Array.from(origins).join(",") : undefined; -} function toTrimmedString(value: unknown): string | null { if (typeof value !== "string") { @@ -246,12 +221,11 @@ function resolveDesktopAppVersion(): string { function resolveStatus(): DesktopDaemonStatus { const home = getPaseoHome(); - const config = loadConfig(home, { env: process.env }); const pidPath = pidFilePath(); let pid: number | null = null; let hostname: string | null = null; - let listen: string = config.listen; + let listen: string | null = null; try { if (existsSync(pidPath)) { @@ -266,7 +240,7 @@ function resolveStatus(): DesktopDaemonStatus { : typeof parsed.sockPath === "string" ? (parsed.sockPath as string) : null; - if (pidListen) listen = pidListen; + listen = pidListen; } } } catch { @@ -297,18 +271,12 @@ async function startDaemon(): Promise { const current = resolveStatus(); if (current.status === "running") return current; - const home = getPaseoHome(); const daemonRunner = resolveDaemonRunnerEntrypoint(); - const corsOrigins = buildDesktopDaemonCorsOriginsEnv(); const invocation = createNodeEntrypointInvocation({ entrypoint: daemonRunner, argvMode: "node-script", args: [], - baseEnv: { - ...process.env, - PASEO_HOME: home, - ...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}), - }, + baseEnv: process.env, }); logDesktopDaemonLifecycle("starting detached daemon", { @@ -317,8 +285,6 @@ async function startDaemon(): Promise { daemonRunnerExecArgv: daemonRunner.execArgv, command: invocation.command, args: invocation.args, - listen: process.env.PASEO_LISTEN ?? null, - corsOrigins: corsOrigins ?? null, }); const child: ChildProcess = spawn( @@ -388,7 +354,7 @@ async function startDaemon(): Promise { serverId: status.serverId || null, }); } - if (status.status === "running" && status.serverId) return status; + if (status.status === "running" && status.serverId && status.listen) return status; await sleep(STARTUP_POLL_INTERVAL_MS); } @@ -439,6 +405,9 @@ async function getDaemonPairing(): Promise { } try { + if (!status.listen) { + throw new Error("Daemon listen target is unavailable."); + } const baseUrl = buildDaemonHttpBaseUrl(status.listen); if (!baseUrl) { throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`); @@ -480,6 +449,10 @@ async function getLocalDaemonVersion(): Promise<{ }; } + if (!status.listen) { + return { version: null, error: "Daemon listen target is unavailable." }; + } + const baseUrl = buildDaemonHttpBaseUrl(status.listen); if (!baseUrl) { return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` }; diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index 18c09e9a6..d2b2ace8c 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -107,7 +107,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec { "server", "dist", "scripts", - "daemon-runner.js", + "supervisor-entrypoint.js", ), }), execArgv: [], @@ -115,7 +115,12 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec { } const serverPackage = resolveServerPackageInfo(); - const distRunner = path.join(serverPackage.root, "dist", "scripts", "daemon-runner.js"); + const distRunner = path.join( + serverPackage.root, + "dist", + "scripts", + "supervisor-entrypoint.js", + ); if (existsSync(distRunner)) { return { entryPath: distRunner, @@ -126,7 +131,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec { return { entryPath: assertPathExists({ label: "Daemon runner source", - filePath: path.join(serverPackage.root, "scripts", "daemon-runner.ts"), + filePath: path.join(serverPackage.root, "scripts", "supervisor-entrypoint.ts"), }), execArgv: ["--import", "tsx"], }; diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index dea166038..581b04ebb 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -7,7 +7,7 @@ dotenv.config({ quiet: true, }); -const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url)); +const daemonRunnerEntry = fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url)); const result = spawnSync( process.execPath, ["--inspect", "--heapsnapshot-near-heap-limit=3", "--max-old-space-size=3072", "--report-on-fatalerror", "--report-directory=/tmp/paseo-reports", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)], diff --git a/packages/server/scripts/supervision-parity.test.ts b/packages/server/scripts/supervision-parity.test.ts index 6aed9f1ff..b88dcb202 100644 --- a/packages/server/scripts/supervision-parity.test.ts +++ b/packages/server/scripts/supervision-parity.test.ts @@ -3,7 +3,10 @@ import { describe, expect, test } from "vitest"; describe("supervision parity", () => { test("has exactly one runtime callsite for runSupervisor", () => { - const daemonRunner = readFileSync(new URL("./daemon-runner.ts", import.meta.url), "utf8"); + const daemonRunner = readFileSync( + new URL("./supervisor-entrypoint.ts", import.meta.url), + "utf8", + ); const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8"); const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length; diff --git a/packages/server/scripts/daemon-runner.ts b/packages/server/scripts/supervisor-entrypoint.ts similarity index 91% rename from packages/server/scripts/daemon-runner.ts rename to packages/server/scripts/supervisor-entrypoint.ts index db6d660e2..519efba9d 100644 --- a/packages/server/scripts/daemon-runner.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -1,8 +1,12 @@ import { fileURLToPath } from "url"; import { existsSync } from "node:fs"; import path from "node:path"; -import { loadConfig } from "../src/server/config.js"; -import { acquirePidLock, PidLockError, releasePidLock } from "../src/server/pid-lock.js"; +import { + acquirePidLock, + PidLockError, + releasePidLock, + updatePidLock, +} from "../src/server/pid-lock.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js"; import { runSupervisor } from "./supervisor.js"; import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; @@ -72,10 +76,7 @@ async function main(): Promise { const config = parseConfig(process.argv.slice(2)); const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); const workerExecArgv = resolveWorkerExecArgv(workerEntry); - const workerEnv: NodeJS.ProcessEnv = { - ...process.env, - PASEO_PID_LOCK_MODE: "external", - }; + const workerEnv: NodeJS.ProcessEnv = { ...process.env }; const packagedNodeEntrypointRunner = process.env.ELECTRON_RUN_AS_NODE === "1" ? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url)) @@ -84,10 +85,9 @@ async function main(): Promise { applySherpaLoaderEnv(workerEnv); const paseoHome = resolvePaseoHome(workerEnv); - const daemonConfig = loadConfig(paseoHome, { env: workerEnv }); try { - await acquirePidLock(paseoHome, daemonConfig.listen, { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid, }); } catch (error) { @@ -135,6 +135,9 @@ async function main(): Promise { }) : undefined, restartOnCrash: config.devMode, + onWorkerReady: async ({ listen }) => { + await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); + }, onSupervisorExit: releaseLock, }); } diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index b661371ab..5a99234e5 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -4,6 +4,10 @@ type WorkerLifecycleMessage = | { type: "paseo:shutdown"; } + | { + type: "paseo:ready"; + listen: string; + } | { type: "paseo:restart"; reason?: string; @@ -21,6 +25,7 @@ type SupervisorOptions = { args: string[]; env?: NodeJS.ProcessEnv; } | null; + onWorkerReady?: (message: { listen: string }) => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; }; @@ -37,6 +42,13 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { if (type === "paseo:shutdown") { return { type: "paseo:shutdown" }; } + if (type === "paseo:ready") { + const listen = (msg as { listen?: unknown }).listen; + if (typeof listen !== "string" || listen.trim().length === 0) { + return null; + } + return { type: "paseo:ready", listen }; + } if (type === "paseo:restart") { const reason = (msg as { reason?: unknown }).reason; return { @@ -110,6 +122,16 @@ export function runSupervisor(options: SupervisorOptions): void { return; } + if (lifecycleMessage.type === "paseo:ready") { + Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch( + (error) => { + const message = error instanceof Error ? error.message : String(error); + log(`Worker ready callback failed: ${message}`); + }, + ); + return; + } + if (lifecycleMessage.type === "paseo:shutdown") { requestShutdown("Shutdown requested by worker"); return; @@ -127,7 +149,11 @@ export function runSupervisor(options: SupervisorOptions): void { return; } - if (restarting || (restartOnCrash && code !== 0 && code !== null)) { + const crashed = + restartOnCrash && + ((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL")); + + if (restarting || crashed) { restarting = false; log(`Worker exited (${exitDescriptor}). Restarting worker...`); spawnWorker(); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 35fa32f4b..043af68a6 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -112,7 +112,6 @@ import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; -import { acquirePidLock, releasePidLock } from "./pid-lock.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { createVoiceMcpSocketBridgeManager, @@ -183,10 +182,6 @@ export type PaseoDaemonConfig = { downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; - pidLock?: { - mode?: "self" | "external"; - ownerPid?: number; - }; }; export interface PaseoDaemon { @@ -207,17 +202,6 @@ export async function createPaseoDaemon( const bootstrapStart = performance.now(); const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const daemonVersion = resolveDaemonVersion(import.meta.url); - const pidLockMode = config.pidLock?.mode ?? "self"; - const pidLockOwnerPid = config.pidLock?.ownerPid; - const ownsPidLock = pidLockMode === "self"; - - // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately. - if (ownsPidLock) { - await acquirePidLock(config.paseoHome, config.listen, { - ownerPid: pidLockOwnerPid, - }); - logger.info({ elapsed: elapsed() }, "PID lock acquired"); - } try { const serverId = getOrCreateServerId(config.paseoHome, { logger }); @@ -707,6 +691,16 @@ export async function createPaseoDaemon( ); } + if (typeof process.send === "function") { + process.send({ + type: "paseo:ready", + listen: + boundListenTarget.type === "tcp" + ? `${boundListenTarget.host}:${boundListenTarget.port}` + : boundListenTarget.path, + }); + } + if (relayEnabled) { const offer = await createConnectionOfferV2({ serverId, @@ -776,12 +770,6 @@ export async function createPaseoDaemon( if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { unlinkSync(listenTarget.path); } - // Release PID lock - if (ownsPidLock) { - await releasePidLock(config.paseoHome, { - ownerPid: pidLockOwnerPid, - }); - } }; return { @@ -794,11 +782,6 @@ export async function createPaseoDaemon( getListenTarget: () => boundListenTarget, }; } catch (err) { - if (ownsPidLock) { - await releasePidLock(config.paseoHome, { - ownerPid: pidLockOwnerPid, - }).catch(() => undefined); - } throw err; } } diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 9d553b499..70b2401d9 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -131,14 +131,10 @@ async function main() { }; try { - const pidLockMode = process.env.PASEO_PID_LOCK_MODE === "external" ? "external" : "self"; daemon = await createPaseoDaemon( { ...config, onLifecycleIntent: handleLifecycleIntent, - pidLock: { - mode: pidLockMode, - }, }, logger, ); diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index befcfd1e4..527655e1b 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { acquirePidLock, getPidLockInfo, releasePidLock } from "./pid-lock.js"; +import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js"; describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { @@ -14,13 +14,25 @@ describe("pid-lock ownership", () => { await ( acquirePidLock as unknown as ( home: string, - sockPath: string, + sockPath: string | null, options: { ownerPid: number }, ) => Promise - )(paseoHome, "127.0.0.1:6767", { ownerPid }); + )(paseoHome, null, { ownerPid }); const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); + expect(lock?.listen).toBeNull(); + + await ( + updatePidLock as unknown as ( + home: string, + patch: { listen: string }, + options: { ownerPid: number }, + ) => Promise + )(paseoHome, { listen: "127.0.0.1:6767" }, { ownerPid }); + + const updatedLock = await getPidLockInfo(paseoHome); + expect(updatedLock?.listen).toBe("127.0.0.1:6767"); await ( releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 9fe584f77..43563a9da 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -8,7 +8,7 @@ export interface PidLockInfo { startedAt: string; hostname: string; uid: number; - listen: string; + listen: string | null; } export class PidLockError extends Error { @@ -43,7 +43,7 @@ function resolveOwnerPid(ownerPid?: number): number { export async function acquirePidLock( paseoHome: string, - listen: string, + listen: string | null, options?: { ownerPid?: number }, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -114,6 +114,37 @@ export async function acquirePidLock( } } +export async function updatePidLock( + paseoHome: string, + patch: { listen: string }, + options?: { ownerPid?: number }, +): Promise { + const pidPath = getPidFilePath(paseoHome); + const lockOwnerPid = resolveOwnerPid(options?.ownerPid); + const content = await readFile(pidPath, "utf-8"); + const existingLock = JSON.parse(content) as PidLockInfo; + + if (existingLock.pid !== lockOwnerPid) { + throw new PidLockError( + `Cannot update PID lock owned by PID ${existingLock.pid}`, + existingLock, + ); + } + + const updatedLock: PidLockInfo = { + ...existingLock, + ...patch, + }; + + const fd = await open(pidPath, "r+"); + try { + await fd.truncate(0); + await fd.writeFile(JSON.stringify(updatedLock)); + } finally { + await fd.close(); + } +} + export async function releasePidLock( paseoHome: string, options?: { ownerPid?: number }, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2d1d32146..24ac670cc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1438,7 +1438,10 @@ export class Session { this.peakInflightRequests = this.inflightRequests; } try { - this.sessionLogger.trace({ inbound: msg }, "inbound message"); + this.sessionLogger.trace( + { messageType: msg.type, payloadBytes: JSON.stringify(msg).length }, + "inbound message", + ); try { switch (msg.type) { case "voice_audio_chunk": @@ -7119,7 +7122,10 @@ export class Session { * Emit a message to the client */ private emit(msg: SessionOutboundMessage): void { - this.sessionLogger.trace({ outbound: msg }, "outbound message"); + this.sessionLogger.trace( + { messageType: msg.type, payloadBytes: JSON.stringify(msg).length }, + "outbound message", + ); if ( msg.type === "audio_output" && (process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) && diff --git a/packages/server/tsconfig.scripts.json b/packages/server/tsconfig.scripts.json index 7e198651c..05a232edb 100644 --- a/packages/server/tsconfig.scripts.json +++ b/packages/server/tsconfig.scripts.json @@ -19,6 +19,6 @@ "declaration": false, "sourceMap": true }, - "include": ["scripts/daemon-runner.ts", "scripts/dev-runner.ts", "scripts/supervisor.ts"], + "include": ["scripts/supervisor-entrypoint.ts", "scripts/dev-runner.ts", "scripts/supervisor.ts"], "exclude": ["node_modules", "dist"] } From 3784357b7e53547033200ac3c466ea58e79eaef7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 12:39:12 +0700 Subject: [PATCH 05/15] docs: add 0.1.38 changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c9ddf64..b8cfc3cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.38 - 2026-03-30 + +### Fixed +- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready. +- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads. + ## 0.1.37 - 2026-03-29 ### Added From 82a235dada11988364b4053b55d7724949e1fa6b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 12:39:23 +0700 Subject: [PATCH 06/15] chore(release): cut 0.1.38 --- 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 941cf9f36..c56fc40f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.37", + "version": "0.1.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.37", + "version": "0.1.38", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34860,16 +34860,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.37", + "version": "0.1.38", "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.37", - "@getpaseo/highlight": "0.1.37", - "@getpaseo/server": "0.1.37", + "@getpaseo/expo-two-way-audio": "0.1.38", + "@getpaseo/highlight": "0.1.38", + "@getpaseo/server": "0.1.38", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -34985,11 +34985,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.37", + "version": "0.1.38", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.37", - "@getpaseo/server": "0.1.37", + "@getpaseo/relay": "0.1.38", + "@getpaseo/server": "0.1.38", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35030,11 +35030,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.37", + "version": "0.1.38", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.37", - "@getpaseo/server": "0.1.37", + "@getpaseo/cli": "0.1.38", + "@getpaseo/server": "0.1.38", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35068,7 +35068,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.37", + "version": "0.1.38", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35269,7 +35269,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.37", + "version": "0.1.38", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35295,7 +35295,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.37", + "version": "0.1.38", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35311,13 +35311,13 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.37", + "version": "0.1.38", "dependencies": { "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.37", - "@getpaseo/relay": "0.1.37", + "@getpaseo/highlight": "0.1.38", + "@getpaseo/relay": "0.1.38", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35715,7 +35715,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.37", + "version": "0.1.38", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index c84b23232..4ca5d9679 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.37", + "version": "0.1.38", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index beeb659b1..c2790038b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.37", + "version": "0.1.38", "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.37", - "@getpaseo/highlight": "0.1.37", - "@getpaseo/server": "0.1.37", + "@getpaseo/expo-two-way-audio": "0.1.38", + "@getpaseo/highlight": "0.1.38", + "@getpaseo/server": "0.1.38", "@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 06a7d9609..6a85477fc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.37", + "version": "0.1.38", "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.37", - "@getpaseo/server": "0.1.37", + "@getpaseo/relay": "0.1.38", + "@getpaseo/server": "0.1.38", "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 7ebb80a1d..0a6182fdf 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.37", + "version": "0.1.38", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.37", - "@getpaseo/server": "0.1.37", + "@getpaseo/cli": "0.1.38", + "@getpaseo/server": "0.1.38", "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 534d82722..cca0026dd 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.37", + "version": "0.1.38", "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 10e1901aa..906a52fa6 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.37", + "version": "0.1.38", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 92de08f4b..a506101ff 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.37", + "version": "0.1.38", "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 a99d632a6..2623bba91 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.37", + "version": "0.1.38", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -63,8 +63,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.37", - "@getpaseo/relay": "0.1.37", + "@getpaseo/highlight": "0.1.38", + "@getpaseo/relay": "0.1.38", "@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 6f78ac0c8..4df8a8921 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.37", + "version": "0.1.38", "private": true, "type": "module", "scripts": { From f6630e16ff222f1324d0027937545cbcd9514fb3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 05:40:49 +0000 Subject: [PATCH 07/15] 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 3ce7d4a09..766bb9246 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-ebbF7pug19PBgJQahUCTlvhcLS/LF8Nv1UIIG31LRmQ="; + npmDepsHash = "sha256-Cz3xidzBIWER4ktn3wWzT9PDm9PnipVA7XnsTQC440U="; # 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 e0e4f228eb2a3192e20b1846c1a8c82fd56b3b1f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 12:43:45 +0700 Subject: [PATCH 08/15] fix(server): guard paseo:ready IPC behind PASEO_SUPERVISED env var The process.send check alone was insufficient because vitest also runs workers with IPC channels. Now the supervisor sets PASEO_SUPERVISED=1 on the worker env, and bootstrap checks for it before sending. --- packages/server/scripts/supervisor-entrypoint.ts | 2 +- packages/server/src/server/bootstrap.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index 519efba9d..73934b0c2 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -76,7 +76,7 @@ async function main(): Promise { const config = parseConfig(process.argv.slice(2)); const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); const workerExecArgv = resolveWorkerExecArgv(workerEntry); - const workerEnv: NodeJS.ProcessEnv = { ...process.env }; + const workerEnv: NodeJS.ProcessEnv = { ...process.env, PASEO_SUPERVISED: "1" }; const packagedNodeEntrypointRunner = process.env.ELECTRON_RUN_AS_NODE === "1" ? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url)) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 043af68a6..0451a6b36 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -691,7 +691,7 @@ export async function createPaseoDaemon( ); } - if (typeof process.send === "function") { + if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") { process.send({ type: "paseo:ready", listen: From db39417be9d43f2c11a89bb1a767c6cbb7a649af Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 13:45:26 +0700 Subject: [PATCH 09/15] fix(app): remove redundant overflow hidden causing iOS sidebar scroll flicker The inner sidebarContent had overflow: "hidden" while the parent mobileSidebar already clips. On iOS, nested overflow clipping on Animated.View with transforms and scroll containers causes rendering artifacts during scroll. --- packages/app/src/components/left-sidebar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index dfe51a89e..bce074c7b 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -811,7 +811,6 @@ const styles = StyleSheet.create((theme) => ({ sidebarContent: { flex: 1, minHeight: 0, - overflow: "hidden", }, desktopSidebar: { position: "relative", From 7504d20b67431dfc772698525ce05f779b7c64a6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 14:07:55 +0700 Subject: [PATCH 10/15] refactor(server): extract SpeechService and defer init until after listen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speech runtime was leaking 8+ individual resolvers into bootstrap and blocking server startup for ~3s with synchronous Sherpa native model loading. Refactor into a self-contained SpeechService that owns its full lifecycle (init, download, monitor, cleanup) and defer start() until after httpServer.listen() so native loading doesn't block accepting connections. Server startup drops from ~3.2s to ~0.5s. Also removes unused client-triggered speech model download/list path (CLI commands, session handlers, message types) — the service manages its own models. --- packages/cli/src/commands/speech/download.ts | 63 --------- packages/cli/src/commands/speech/index.ts | 19 +-- packages/cli/src/commands/speech/models.ts | 67 --------- packages/server/src/client/daemon-client.ts | 30 ---- packages/server/src/server/bootstrap.ts | 32 ++--- packages/server/src/server/session.ts | 128 +----------------- .../src/server/speech/speech-runtime.test.ts | 20 +-- .../src/server/speech/speech-runtime.ts | 83 ++++++++---- .../server/src/server/websocket-server.ts | 55 +++----- packages/server/src/shared/messages.ts | 47 ------- 10 files changed, 100 insertions(+), 444 deletions(-) delete mode 100644 packages/cli/src/commands/speech/download.ts delete mode 100644 packages/cli/src/commands/speech/models.ts diff --git a/packages/cli/src/commands/speech/download.ts b/packages/cli/src/commands/speech/download.ts deleted file mode 100644 index ec5f71a4c..000000000 --- a/packages/cli/src/commands/speech/download.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Command } from "commander"; -import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; -import { connectToDaemon } from "../../utils/client.js"; - -interface SpeechDownloadRow { - modelId: string; - status: "downloaded"; -} - -const speechDownloadSchema: OutputSchema = { - idField: "modelId", - columns: [ - { header: "MODEL", field: "modelId", width: 36 }, - { header: "STATUS", field: "status", width: 12, color: () => "green" }, - ], -}; - -export type SpeechDownloadResult = ListResult; - -export interface SpeechDownloadOptions extends CommandOptions { - host?: string; - model?: string[]; -} - -export async function runSpeechDownloadCommand( - options: SpeechDownloadOptions, - _command: Command, -): Promise { - const client = await connectToDaemon({ host: options.host }); - try { - const response = await client.downloadSpeechModels({ - modelIds: options.model && options.model.length > 0 ? options.model : undefined, - }); - if (response.error) { - const commandError: CommandError = { - code: "SPEECH_MODELS_DOWNLOAD_FAILED", - message: response.error, - }; - throw commandError; - } - - return { - type: "list", - data: response.downloadedModelIds.map((modelId) => ({ - modelId, - status: "downloaded" as const, - })), - schema: speechDownloadSchema, - }; - } catch (error) { - if (typeof error === "object" && error && "code" in error && "message" in error) { - throw error; - } - const message = error instanceof Error ? error.message : String(error); - const commandError: CommandError = { - code: "SPEECH_MODELS_DOWNLOAD_FAILED", - message: `Failed to download speech models: ${message}`, - }; - throw commandError; - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/cli/src/commands/speech/index.ts b/packages/cli/src/commands/speech/index.ts index 4fd451d29..e84d55b60 100644 --- a/packages/cli/src/commands/speech/index.ts +++ b/packages/cli/src/commands/speech/index.ts @@ -1,22 +1,5 @@ import { Command } from "commander"; -import { withOutput } from "../../output/index.js"; -import { runSpeechModelsCommand } from "./models.js"; -import { runSpeechDownloadCommand } from "./download.js"; -import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js"; export function createSpeechCommand(): Command { - const speech = new Command("speech").description("Manage local speech models"); - - addJsonAndDaemonHostOptions( - speech.command("models").description("List local speech model download status"), - ).action(withOutput(runSpeechModelsCommand)); - - addJsonAndDaemonHostOptions( - speech - .command("download") - .description("Download local speech models") - .option("--model ", "Model ID to download (repeatable)", collectMultiple, []), - ).action(withOutput(runSpeechDownloadCommand)); - - return speech; + return new Command("speech").description("Speech commands"); } diff --git a/packages/cli/src/commands/speech/models.ts b/packages/cli/src/commands/speech/models.ts deleted file mode 100644 index 9b5ca5d50..000000000 --- a/packages/cli/src/commands/speech/models.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Command } from "commander"; -import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; -import { connectToDaemon } from "../../utils/client.js"; - -interface SpeechModelListItem { - id: string; - kind: string; - status: "downloaded" | "missing"; - modelDir: string; - missingFiles: string; -} - -const speechModelsSchema: OutputSchema = { - idField: "id", - columns: [ - { header: "MODEL", field: "id", width: 36 }, - { header: "KIND", field: "kind", width: 12 }, - { - header: "STATUS", - field: "status", - width: 12, - color: (value) => (value === "downloaded" ? "green" : "yellow"), - }, - { header: "MODEL DIR", field: "modelDir", width: 44 }, - { header: "MISSING FILES", field: "missingFiles", width: 40 }, - ], -}; - -export type SpeechModelsResult = ListResult; - -export interface SpeechModelsOptions extends CommandOptions { - host?: string; -} - -export async function runSpeechModelsCommand( - options: SpeechModelsOptions, - _command: Command, -): Promise { - const client = await connectToDaemon({ host: options.host }); - try { - const response = await client.listSpeechModels(); - const rows: SpeechModelListItem[] = response.models - .slice() - .sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id)) - .map((model) => ({ - id: model.id, - kind: model.kind, - status: model.isDownloaded ? "downloaded" : "missing", - modelDir: model.modelDir, - missingFiles: model.missingFiles?.join(", ") ?? "", - })); - return { - type: "list", - data: rows, - schema: speechModelsSchema, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const commandError: CommandError = { - code: "SPEECH_MODELS_LIST_FAILED", - message: `Failed to list speech models: ${message}`, - }; - throw commandError; - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index a9c12c341..450fa05da 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -39,8 +39,6 @@ import type { ListCommandsResponse, ListProviderModelsResponseMessage, ListAvailableProvidersResponse, - SpeechModelsListResponse, - SpeechModelsDownloadResponse, ListTerminalsResponse, CreateTerminalResponse, SubscribeTerminalResponse, @@ -217,8 +215,6 @@ type FileExplorerPayload = FileExplorerResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; -type SpeechModelsListPayload = SpeechModelsListResponse["payload"]; -type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, @@ -2487,32 +2483,6 @@ export class DaemonClient { }); } - async listSpeechModels(requestId?: string): Promise { - return this.sendCorrelatedSessionRequest({ - requestId, - message: { - type: "speech_models_list_request", - }, - responseType: "speech_models_list_response", - timeout: 30000, - }); - } - - async downloadSpeechModels(options?: { - modelIds?: string[]; - requestId?: string; - }): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: options?.requestId, - message: { - type: "speech_models_download_request", - modelIds: options?.modelIds, - }, - responseType: "speech_models_download_response", - timeout: 30 * 60 * 1000, - }); - } - async listCommands(agentId: string, requestId?: string): Promise; async listCommands(agentId: string, options?: ListCommandsOptions): Promise; async listCommands( diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0451a6b36..e73fe74a6 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -91,7 +91,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; -import { initializeSpeechRuntime } from "./speech/speech-runtime.js"; +import { createSpeechService } from "./speech/speech-runtime.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; @@ -426,7 +426,6 @@ export async function createPaseoDaemon( logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); let wsServer: VoiceAssistantWebSocketServer | null = null; let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null; - let unsubscribeSpeechReadiness: (() => void) | null = null; // Create in-memory transport for Session's Agent MCP client (voice assistant tools) const createInMemoryAgentMcpTransport = async (): Promise => { @@ -589,21 +588,12 @@ export async function createPaseoDaemon( }); }, }); - const { - resolveVoiceTurnDetection, - resolveVoiceStt, - resolveVoiceTts, - resolveDictationStt, - getSpeechReadiness, - subscribeSpeechReadiness, - cleanup: cleanupSpeechRuntime, - localModelConfig, - } = await initializeSpeechRuntime({ + const speechService = createSpeechService({ logger, openaiConfig: config.openai, speechConfig: config.speech, }); - logger.info({ elapsed: elapsed() }, "Speech runtime initialized"); + logger.info({ elapsed: elapsed() }, "Speech service created"); wsServer = new VoiceAssistantWebSocketServer( httpServer, @@ -615,7 +605,7 @@ export async function createPaseoDaemon( config.paseoHome, createInMemoryAgentMcpTransport, { allowedOrigins, allowedHosts: config.allowedHosts }, - { turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts }, + speechService, terminalManager, { voiceAgentMcpStdio: { @@ -633,9 +623,6 @@ export async function createPaseoDaemon( }, { finalTimeoutMs: config.dictationFinalTimeoutMs, - stt: resolveDictationStt, - localModels: localModelConfig ?? undefined, - getSpeechReadiness, }, config.agentProviderSettings, daemonVersion, @@ -653,9 +640,6 @@ export async function createPaseoDaemon( scheduleService, checkoutDiffManager, ); - unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => { - wsServer?.publishSpeechReadiness(snapshot); - }); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); @@ -741,6 +725,10 @@ export async function createPaseoDaemon( httpServer.listen(listenTarget.path); } }); + + // Start speech service after listening so synchronous Sherpa native + // model loading doesn't block the server from accepting connections. + speechService.start(); }; const stop = async () => { @@ -752,9 +740,7 @@ export async function createPaseoDaemon( runtimeSettings: config.agentProviderSettings, }); terminalManager.killAll(); - unsubscribeSpeechReadiness?.(); - unsubscribeSpeechReadiness = null; - cleanupSpeechRuntime(); + speechService.stop(); await scheduleService.stop().catch(() => undefined); await relayTransport?.stop().catch(() => undefined); if (wsServer) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 24ac670cc..61d593c92 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -170,12 +170,7 @@ import { toCheckoutError, } from "./checkout-git-utils.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, - type LocalSpeechModelId, -} from "./speech/providers/local/models.js"; +import type { LocalSpeechModelId } from "./speech/providers/local/models.js"; import { toResolver, type Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js"; import type pino from "pino"; @@ -401,10 +396,6 @@ export type SessionOptions = { dictation?: { finalTimeoutMs?: number; stt?: Resolvable; - localModels?: { - modelsDir: string; - defaultModelIds: LocalSpeechModelId[]; - }; getSpeechReadiness?: () => SpeechReadinessSnapshot; }; agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; @@ -596,8 +587,6 @@ export class Session { private readonly checkoutDiffSubscriptions = new Map void>(); private readonly workspaceGitWatchTargets = new Map(); private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; - private readonly localSpeechModelsDir: string; - private readonly defaultLocalSpeechModelIds: LocalSpeechModelId[]; private readonly registerVoiceSpeakHandler?: ( agentId: string, handler: VoiceSpeakHandler, @@ -669,15 +658,6 @@ export class Session { } this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); - const configuredModelsDir = dictation?.localModels?.modelsDir?.trim(); - this.localSpeechModelsDir = - configuredModelsDir && configuredModelsDir.length > 0 - ? configuredModelsDir - : join(this.paseoHome, "models", "local-speech"); - this.defaultLocalSpeechModelIds = - dictation?.localModels?.defaultModelIds && dictation.localModels.defaultModelIds.length > 0 - ? [...new Set(dictation.localModels.defaultModelIds)] - : ["parakeet-tdt-0.6b-v2-int8", "kokoro-en-v0_19"]; this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler; this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext; @@ -1665,14 +1645,6 @@ export class Session { await this.handleListAvailableProvidersRequest(msg); break; - case "speech_models_list_request": - await this.handleSpeechModelsListRequest(msg); - break; - - case "speech_models_download_request": - await this.handleSpeechModelsDownloadRequest(msg); - break; - case "clear_agent_attention": await this.handleClearAgentAttention(msg.agentId); break; @@ -3062,104 +3034,6 @@ export class Session { } } - private async handleSpeechModelsListRequest( - msg: Extract, - ): Promise { - const modelsDir = this.localSpeechModelsDir; - - const models = await Promise.all( - listLocalSpeechModels().map(async (model) => { - const modelDir = getLocalSpeechModelDir(modelsDir, model.id); - const missingFiles: string[] = []; - for (const rel of model.requiredFiles) { - const filePath = join(modelDir, rel); - try { - const fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - continue; - } - if (!fileStat.isFile() || fileStat.size <= 0) { - missingFiles.push(rel); - } - } catch { - missingFiles.push(rel); - } - } - - return { - id: model.id, - kind: model.kind, - description: model.description, - modelDir, - isDownloaded: missingFiles.length === 0, - ...(missingFiles.length > 0 ? { missingFiles } : {}), - }; - }), - ); - - this.emit({ - type: "speech_models_list_response", - payload: { - modelsDir, - models, - requestId: msg.requestId, - }, - }); - } - - private async handleSpeechModelsDownloadRequest( - msg: Extract, - ): Promise { - const modelsDir = this.localSpeechModelsDir; - - const modelIdsRaw = - msg.modelIds && msg.modelIds.length > 0 ? msg.modelIds : this.defaultLocalSpeechModelIds; - - const allModelIds = new Set(listLocalSpeechModels().map((m) => m.id)); - const invalid = modelIdsRaw.filter((id) => !allModelIds.has(id as LocalSpeechModelId)); - if (invalid.length > 0) { - this.emit({ - type: "speech_models_download_response", - payload: { - modelsDir, - downloadedModelIds: [], - error: `Unknown speech model id(s): ${invalid.join(", ")}`, - requestId: msg.requestId, - }, - }); - return; - } - - const modelIds = modelIdsRaw as LocalSpeechModelId[]; - try { - await ensureLocalSpeechModels({ - modelsDir, - modelIds, - logger: this.sessionLogger, - }); - this.emit({ - type: "speech_models_download_response", - payload: { - modelsDir, - downloadedModelIds: modelIds, - error: null, - requestId: msg.requestId, - }, - }); - } catch (error) { - this.sessionLogger.error({ err: error, modelIds }, "Failed to download speech models"); - this.emit({ - type: "speech_models_download_response", - payload: { - modelsDir, - downloadedModelIds: [], - error: error instanceof Error ? error.message : String(error), - requestId: msg.requestId, - }, - }); - } - } - private normalizeGitOptions( gitOptions?: GitSetupOptions, legacyWorktreeName?: string, diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index e1ddf2ae2..a891e7656 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -5,7 +5,7 @@ import type { PaseoSpeechConfig } from "../bootstrap.js"; import type { InitializedLocalSpeech } from "./providers/local/runtime.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js"; import type { TurnDetectionProvider } from "./turn-detection-provider.js"; -import { initializeSpeechRuntime } from "./speech-runtime.js"; +import { createSpeechService } from "./speech-runtime.js"; const { initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise>(), @@ -70,7 +70,7 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe return { providers }; } -describe("initializeSpeechRuntime readiness", () => { +describe("createSpeechService readiness", () => { beforeEach(() => { initializeLocalSpeechServicesMock.mockReset(); }); @@ -92,7 +92,7 @@ describe("initializeSpeechRuntime readiness", () => { cleanup: () => {}, }); - const runtime = await initializeSpeechRuntime({ + const runtime = createSpeechService({ logger: pino({ level: "silent" }), speechConfig: createSpeechConfig({ dictationStt: { provider: "local", enabled: true, explicit: true }, @@ -101,14 +101,16 @@ describe("initializeSpeechRuntime readiness", () => { voiceTts: { provider: "local", enabled: false, explicit: true }, }), }); + runtime.start(); + await runtime.ready; - const readiness = runtime.getSpeechReadiness(); + const readiness = runtime.getReadiness(); expect(readiness.dictation.available).toBe(true); expect(readiness.realtimeVoice.reasonCode).toBe("disabled"); expect(readiness.voiceFeature.available).toBe(true); expect(readiness.voiceFeature.reasonCode).toBe("ready"); - runtime.cleanup(); + runtime.stop(); }); it("keeps voice feature available when only realtime voice is enabled and ready", async () => { @@ -130,7 +132,7 @@ describe("initializeSpeechRuntime readiness", () => { cleanup: () => {}, }); - const runtime = await initializeSpeechRuntime({ + const runtime = createSpeechService({ logger: pino({ level: "silent" }), speechConfig: createSpeechConfig({ dictationStt: { provider: "local", enabled: false, explicit: true }, @@ -139,13 +141,15 @@ describe("initializeSpeechRuntime readiness", () => { voiceTts: { provider: "local", enabled: true, explicit: true }, }), }); + runtime.start(); + await runtime.ready; - const readiness = runtime.getSpeechReadiness(); + const readiness = runtime.getReadiness(); expect(readiness.realtimeVoice.available).toBe(true); expect(readiness.dictation.reasonCode).toBe("disabled"); expect(readiness.voiceFeature.available).toBe(true); expect(readiness.voiceFeature.reasonCode).toBe("ready"); - runtime.cleanup(); + runtime.stop(); }); }); diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 2781361b5..c56c19296 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -336,25 +336,23 @@ function resolveEffectiveProviderIds(params: { }; } -export type InitializedSpeechRuntime = { - resolveVoiceTurnDetection: () => TurnDetectionProvider | null; - resolveVoiceStt: () => SpeechToTextProvider | null; - resolveVoiceTts: () => TextToSpeechProvider | null; +export type SpeechService = { + resolveStt: () => SpeechToTextProvider | null; + resolveTts: () => TextToSpeechProvider | null; + resolveTurnDetection: () => TurnDetectionProvider | null; resolveDictationStt: () => SpeechToTextProvider | null; - getSpeechReadiness: () => SpeechReadinessSnapshot; - subscribeSpeechReadiness: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void; - cleanup: () => void; - localModelConfig: { - modelsDir: string; - defaultModelIds: LocalSpeechModelId[]; - } | null; + getReadiness: () => SpeechReadinessSnapshot; + onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void; + start: () => void; + stop: () => void; + ready: Promise; }; -export async function initializeSpeechRuntime(params: { +export function createSpeechService(params: { logger: Logger; openaiConfig?: PaseoOpenAIConfig; speechConfig?: PaseoSpeechConfig; -}): Promise { +}): SpeechService { const logger = params.logger.child({ module: "speech-runtime" }); const speechConfig = params.speechConfig ?? null; const openaiConfig = params.openaiConfig; @@ -397,6 +395,14 @@ export async function initializeSpeechRuntime(params: { const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>(); let lastReadinessFingerprint: string | null = null; let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null; + let started = false; + let readySettled = false; + let resolveReady!: () => void; + let rejectReady!: (error: unknown) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); const computeReadinessSnapshot = (): SpeechReadinessSnapshot => { const realtimeVoice = buildRealtimeVoiceReadiness({ @@ -658,16 +664,36 @@ export async function initializeSpeechRuntime(params: { } }; - await runReconcile(); - const snapshot = computeReadinessSnapshot(); - if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { - if (missingLocalModelIds.length > 0) { - startBackgroundDownload(); + const start = (): void => { + if (started || stopped) { + return; } - scheduleMonitor(); - } + started = true; + void (async () => { + try { + await runReconcile(); + const snapshot = computeReadinessSnapshot(); + if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { + if (missingLocalModelIds.length > 0) { + startBackgroundDownload(); + } + scheduleMonitor(); + } + if (!readySettled) { + readySettled = true; + resolveReady(); + } + } catch (error) { + if (!readySettled) { + readySettled = true; + rejectReady(error); + } + logger.error({ err: error }, "Speech runtime failed during initial reconcile"); + } + })(); + }; - const cleanup = (): void => { + const stop = (): void => { stopped = true; if (monitorTimeout) { clearTimeout(monitorTimeout); @@ -677,13 +703,14 @@ export async function initializeSpeechRuntime(params: { }; return { - resolveVoiceTurnDetection: () => turnDetectionService, - resolveVoiceStt: () => sttService, - resolveVoiceTts: () => ttsService, + resolveTurnDetection: () => turnDetectionService, + resolveStt: () => sttService, + resolveTts: () => ttsService, resolveDictationStt: () => dictationSttService, - getSpeechReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(), - subscribeSpeechReadiness, - cleanup, - localModelConfig, + getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(), + onReadinessChange: subscribeSpeechReadiness, + start, + stop, + ready, }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ddd6a29ba..ab9d0f787 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -33,11 +33,7 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; -import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; -import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"; -import type { Resolvable } from "./speech/provider-resolver.js"; -import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; -import type { LocalSpeechModelId } from "./speech/providers/local/models.js"; +import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import { computeShouldNotifyClient, @@ -243,18 +239,10 @@ export class VoiceAssistantWebSocketServer { private readonly pushTokenStore: PushTokenStore; private readonly pushService: PushService; private readonly createAgentMcpTransport: AgentMcpTransportFactory; - private readonly stt: Resolvable; - private readonly tts: Resolvable; - private readonly turnDetection: Resolvable; + private readonly speech: SpeechService | null; private readonly terminalManager: TerminalManager | null; private readonly dictation: { finalTimeoutMs?: number; - stt?: Resolvable; - localModels?: { - modelsDir: string; - defaultModelIds: LocalSpeechModelId[]; - }; - getSpeechReadiness?: () => SpeechReadinessSnapshot; } | null; private readonly voice: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; @@ -288,6 +276,7 @@ export class VoiceAssistantWebSocketServer { private readonly inboundSessionRequestCounts = new Map(); private readonly requestLatencies = new Map(); private runtimeMetricsInterval: ReturnType | null = null; + private unsubscribeSpeechReadiness: (() => void) | null = null; constructor( server: HTTPServer, @@ -299,11 +288,7 @@ export class VoiceAssistantWebSocketServer { paseoHome: string, createAgentMcpTransport: AgentMcpTransportFactory, wsConfig: WebSocketServerConfig, - speech?: { - turnDetection: Resolvable; - stt: Resolvable; - tts: Resolvable; - }, + speech?: SpeechService | null, terminalManager?: TerminalManager | null, voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; @@ -312,12 +297,6 @@ export class VoiceAssistantWebSocketServer { }, dictation?: { finalTimeoutMs?: number; - stt?: Resolvable; - localModels?: { - modelsDir: string; - defaultModelIds: LocalSpeechModelId[]; - }; - getSpeechReadiness?: () => SpeechReadinessSnapshot; }, agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, daemonVersion?: string, @@ -358,17 +337,18 @@ export class VoiceAssistantWebSocketServer { this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.createAgentMcpTransport = createAgentMcpTransport; - this.turnDetection = speech?.turnDetection ?? null; - this.stt = speech?.stt ?? null; - this.tts = speech?.tts ?? null; + this.speech = speech ?? null; this.terminalManager = terminalManager ?? null; this.voice = voice ?? null; this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.onLifecycleIntent = onLifecycleIntent ?? null; this.serverCapabilities = buildServerCapabilities({ - readiness: this.dictation?.getSpeechReadiness?.() ?? null, + readiness: this.speech?.getReadiness() ?? null, }); + this.unsubscribeSpeechReadiness = this.speech?.onReadinessChange((snapshot) => { + this.publishSpeechReadiness(snapshot); + }) ?? null; const pushLogger = this.logger.child({ module: "push" }); this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json")); @@ -457,6 +437,8 @@ export class VoiceAssistantWebSocketServer { } public async close(): Promise { + this.unsubscribeSpeechReadiness?.(); + this.unsubscribeSpeechReadiness = null; if (this.runtimeMetricsInterval) { clearInterval(this.runtimeMetricsInterval); this.runtimeMetricsInterval = null; @@ -658,12 +640,12 @@ export class VoiceAssistantWebSocketServer { scheduleService: this.scheduleService, checkoutDiffManager: this.checkoutDiffManager, createAgentMcpTransport: this.createAgentMcpTransport, - stt: this.stt, - tts: this.tts, + stt: () => this.speech?.resolveStt() ?? null, + tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, voice: { ...(this.voice ?? {}), - turnDetection: this.turnDetection, + turnDetection: () => this.speech?.resolveTurnDetection() ?? null, }, voiceBridge: { registerVoiceSpeakHandler: (agentId, handler) => { @@ -681,7 +663,14 @@ export class VoiceAssistantWebSocketServer { ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent, removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent, }, - dictation: this.dictation ?? undefined, + dictation: + this.dictation || this.speech + ? { + finalTimeoutMs: this.dictation?.finalTimeoutMs, + stt: () => this.speech?.resolveDictationStt() ?? null, + getSpeechReadiness: () => this.speech!.getReadiness(), + } + : undefined, agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 6ac99e5e8..9885aaf27 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -726,17 +726,6 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({ requestId: z.string(), }); -export const SpeechModelsListRequestSchema = z.object({ - type: z.literal("speech_models_list_request"), - requestId: z.string(), -}); - -export const SpeechModelsDownloadRequestSchema = z.object({ - type: z.literal("speech_models_download_request"), - modelIds: z.array(z.string()).optional(), - requestId: z.string(), -}); - export const ResumeAgentRequestMessageSchema = z.object({ type: z.literal("resume_agent_request"), handle: AgentPersistenceHandleSchema, @@ -1203,8 +1192,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CreateAgentRequestMessageSchema, ListProviderModelsRequestMessageSchema, ListAvailableProvidersRequestMessageSchema, - SpeechModelsListRequestSchema, - SpeechModelsDownloadRequestSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, CancelAgentRequestMessageSchema, @@ -2104,34 +2091,6 @@ export const ListAvailableProvidersResponseSchema = z.object({ }), }); -export const SpeechModelsListResponseSchema = z.object({ - type: z.literal("speech_models_list_response"), - payload: z.object({ - modelsDir: z.string(), - models: z.array( - z.object({ - id: z.string(), - kind: z.string(), - description: z.string(), - modelDir: z.string(), - isDownloaded: z.boolean(), - missingFiles: z.array(z.string()).optional(), - }), - ), - requestId: z.string(), - }), -}); - -export const SpeechModelsDownloadResponseSchema = z.object({ - type: z.literal("speech_models_download_response"), - payload: z.object({ - modelsDir: z.string(), - downloadedModelIds: z.array(z.string()), - error: z.string().nullable(), - requestId: z.string(), - }), -}); - const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), @@ -2311,8 +2270,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FileDownloadTokenResponseSchema, ListProviderModelsResponseMessageSchema, ListAvailableProvidersResponseSchema, - SpeechModelsListResponseSchema, - SpeechModelsDownloadResponseSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -2381,8 +2338,6 @@ export type ListProviderModelsResponseMessage = z.infer< typeof ListProviderModelsResponseMessageSchema >; export type ListAvailableProvidersResponse = z.infer; -export type SpeechModelsListResponse = z.infer; -export type SpeechModelsDownloadResponse = z.infer; export type ChatCreateResponse = z.infer; export type ChatListResponse = z.infer; export type ChatInspectResponse = z.infer; @@ -2443,8 +2398,6 @@ export type LoopListRequest = z.infer; export type LoopInspectRequest = z.infer; export type LoopLogsRequest = z.infer; export type LoopStopRequest = z.infer; -export type SpeechModelsListRequestMessage = z.infer; -export type SpeechModelsDownloadRequestMessage = z.infer; export type ResumeAgentRequestMessage = z.infer; export type DeleteAgentRequestMessage = z.infer; export type UpdateAgentRequestMessage = z.infer; From a94bc0720cc3c9a2a1da6acb24c9ec11531c65eb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 14:53:27 +0700 Subject: [PATCH 11/15] =?UTF-8?q?refactor(server):=20rename=20worktree=20d?= =?UTF-8?q?estroy=20=E2=86=92=20teardown,=20add=20port=20to=20env,=20extra?= =?UTF-8?q?ct=20session=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `worktree.destroy` config field to `worktree.teardown` (breaking) - Rename getWorktreeDestroyCommands/runWorktreeDestroyCommands → teardown - Add PASEO_WORKTREE_PORT to teardown env from persisted metadata (omit if missing) - Extract worktree orchestration from session.ts into worktree-session.ts - Document teardown lifecycle hooks in worktree docs --- .../daemon-e2e/git-operations.e2e.test.ts | 12 +- packages/server/src/server/session.ts | 608 +++------------ .../server/src/server/worktree-session.ts | 721 ++++++++++++++++++ packages/server/src/utils/worktree.test.ts | 89 ++- packages/server/src/utils/worktree.ts | 48 +- .../website/src/routes/docs/worktrees.tsx | 55 +- 6 files changed, 953 insertions(+), 580 deletions(-) create mode 100644 packages/server/src/server/worktree-session.ts diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index e4af15564..2f5381c86 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -730,7 +730,7 @@ describe("daemon E2E", () => { }); describe("archivePaseoWorktree", () => { - test("archives worktree by running destroy commands and shutting down worktree terminals", async () => { + test("archives worktree by running teardown commands and shutting down worktree terminals", async () => { const repoRoot = tmpCwd(); const { execSync } = await import("child_process"); @@ -749,7 +749,7 @@ describe("daemon E2E", () => { }); execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); - const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt"); + const teardownMarkerPath = path.join(repoRoot, "teardown-marker.txt"); writeFileSync( path.join(repoRoot, "paseo.json"), JSON.stringify({ @@ -760,12 +760,12 @@ describe("daemon E2E", () => { command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', }, ], - destroy: [`echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`], + teardown: [`echo "$PASEO_WORKTREE_PATH" > "${teardownMarkerPath}"`], }, }), ); execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", { + execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + teardown'", { cwd: repoRoot, stdio: "pipe", }); @@ -813,8 +813,8 @@ describe("daemon E2E", () => { expect(archive.removedAgents).toContain(agent.id); expect(existsSync(agent.cwd)).toBe(false); - expect(existsSync(destroyMarkerPath)).toBe(true); - expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd); + expect(existsSync(teardownMarkerPath)).toBe(true); + expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd); const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); expect(afterArchiveDirectories).not.toContain(agent.cwd); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 61d593c92..8b5ecbbd8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -136,22 +136,12 @@ import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; import { type WorktreeConfig, - computeWorktreePath, - getWorktreeSetupCommands, - resolveWorktreeRuntimeEnv, - slugify, - validateBranchSlug, - listPaseoWorktrees, - deletePaseoWorktree, - isPaseoOwnedWorktreeCwd, - resolvePaseoWorktreeRootForCwd, } from "../utils/worktree.js"; -import { createAgentWorktree, runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; +import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import { getCheckoutDiff, getCheckoutShortstat, getCheckoutStatus, - getCheckoutStatusLite, listBranchSuggestions, commitChanges, mergeToBase, @@ -159,7 +149,6 @@ import { pushCurrentBranch, createPullRequest, getPullRequestStatus, - resolveRepositoryDefaultBranch, } from "../utils/checkout-git.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; @@ -182,6 +171,16 @@ import { import { notifyChatMentions } from "./chat/chat-mentions.js"; import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; +import { + assertSafeGitRef as assertWorktreeSafeGitRef, + buildAgentSessionConfig as buildWorktreeAgentSessionConfig, + createPaseoWorktreeInBackground as createWorktreeInBackgroundSession, + handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, + handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, + handlePaseoWorktreeListRequest as handleWorktreeListRequest, + killTerminalsUnderPath as killWorktreeTerminalsUnderPath, + registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession, +} from "./worktree-session.js"; const execAsync = promisify(exec); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); @@ -238,14 +237,6 @@ type WorkspaceGitWatchTarget = { latestFingerprint: string | null; }; -type NormalizedGitOptions = { - baseBranch?: string; - createNewBranch: boolean; - newBranchName?: string; - createWorktree: boolean; - worktreeSlug?: string; -}; - type ActiveTerminalStream = { terminalId: string; slot: number; @@ -330,7 +321,6 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000; const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS, ); -const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; const AgentIdSchema = z.string().uuid(); const VOICE_MCP_SERVER_NAME = "paseo_voice"; const VOICE_INTERRUPT_CONFIRMATION_MS = 500; @@ -2904,70 +2894,18 @@ export class Session { legacyWorktreeName?: string, _labels?: Record, ): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> { - let cwd = expandTilde(config.cwd); - const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName); - let worktreeConfig: WorktreeConfig | undefined; - - if (!normalized) { - return { - sessionConfig: { - ...config, - cwd, - }, - }; - } - - if (normalized.createWorktree) { - let targetBranch: string; - - if (normalized.createNewBranch) { - targetBranch = normalized.newBranchName!; - } else { - // Resolve current branch name from HEAD - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - targetBranch = stdout.trim(); - } - - if (!targetBranch) { - throw new Error("A branch name is required when creating a worktree."); - } - - this.sessionLogger.info( - { worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch }, - `Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`, - ); - - const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd)); - const createdWorktree = await createAgentWorktree({ - branchName: targetBranch, - cwd, - baseBranch, - worktreeSlug: normalized.worktreeSlug ?? targetBranch, + return buildWorktreeAgentSessionConfig( + { paseoHome: this.paseoHome, - }); - cwd = createdWorktree.worktreePath; - worktreeConfig = createdWorktree; - } else if (normalized.createNewBranch) { - const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd)); - await this.createBranchFromBase({ - cwd, - baseBranch, - newBranchName: normalized.newBranchName!, - }); - } else if (normalized.baseBranch) { - await this.checkoutExistingBranch(cwd, normalized.baseBranch); - } - - return { - sessionConfig: { - ...config, - cwd, + sessionLogger: this.sessionLogger, + checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch), + createBranchFromBase: (params) => this.createBranchFromBase(params), }, - worktreeConfig, - }; + config, + gitOptions, + legacyWorktreeName, + _labels, + ); } private async handleListProviderModelsRequest( @@ -3034,84 +2972,8 @@ export class Session { } } - private normalizeGitOptions( - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - ): NormalizedGitOptions | null { - const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName - ? { - createWorktree: true, - createNewBranch: true, - newBranchName: legacyWorktreeName, - worktreeSlug: legacyWorktreeName, - } - : undefined; - - const merged = gitOptions ?? fallbackOptions; - if (!merged) { - return null; - } - - const baseBranch = merged.baseBranch?.trim() || undefined; - const createWorktree = Boolean(merged.createWorktree); - const createNewBranch = Boolean(merged.createNewBranch); - const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined; - const normalizedWorktreeSlug = merged.worktreeSlug - ? slugify(merged.worktreeSlug) - : normalizedBranchName; - - if (!createWorktree && !createNewBranch && !baseBranch) { - return null; - } - - if (baseBranch) { - this.assertSafeGitRef(baseBranch, "base branch"); - } - - if (createNewBranch) { - if (!normalizedBranchName) { - throw new Error("New branch name is required"); - } - const validation = validateBranchSlug(normalizedBranchName); - if (!validation.valid) { - throw new Error(`Invalid branch name: ${validation.error}`); - } - } - - if (normalizedWorktreeSlug) { - const validation = validateBranchSlug(normalizedWorktreeSlug); - if (!validation.valid) { - throw new Error(`Invalid worktree name: ${validation.error}`); - } - } - - return { - baseBranch, - createNewBranch, - newBranchName: normalizedBranchName, - createWorktree, - worktreeSlug: normalizedWorktreeSlug, - }; - } - private assertSafeGitRef(ref: string, label: string): void { - if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) { - throw new Error(`Invalid ${label}: ${ref}`); - } - } - - private async resolveGitCreateBaseBranch(cwd: string): Promise { - const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome }); - if (!checkout.isGit) { - throw new Error("Cannot create a worktree outside a git repository"); - } - - const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd; - const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); - if (!baseBranch) { - throw new Error("Unable to resolve repository default branch"); - } - return baseBranch; + assertWorktreeSafeGitRef(ref, label); } private isPathWithinRoot(rootPath: string, candidatePath: string): boolean { @@ -4346,193 +4208,32 @@ export class Session { private async handlePaseoWorktreeListRequest( msg: Extract, ): Promise { - const { requestId } = msg; - const cwd = msg.repoRoot ?? msg.cwd; - if (!cwd) { - this.emit({ - type: "paseo_worktree_list_response", - payload: { - worktrees: [], - error: { code: "UNKNOWN", message: "cwd or repoRoot is required" }, - requestId, - }, - }); - return; - } - - try { - const worktrees = await listPaseoWorktrees({ cwd, paseoHome: this.paseoHome }); - this.emit({ - type: "paseo_worktree_list_response", - payload: { - worktrees: worktrees.map((entry) => ({ - worktreePath: entry.path, - createdAt: entry.createdAt, - branchName: entry.branchName ?? null, - head: entry.head ?? null, - })), - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "paseo_worktree_list_response", - payload: { - worktrees: [], - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async archivePaseoWorktree(options: { - targetPath: string; - repoRoot: string; - requestId: string; - }): Promise { - let targetPath = options.targetPath; - const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { - paseoHome: this.paseoHome, - }); - if (resolvedWorktree) { - targetPath = resolvedWorktree.worktreePath; - } - - const removedAgents = new Set(); - const affectedWorkspaceCwds = new Set([targetPath]); - const affectedWorkspaceIds = new Set([normalizePersistedWorkspaceId(targetPath)]); - const agents = this.agentManager.listAgents(); - for (const agent of agents) { - if (this.isPathWithinRoot(targetPath, agent.cwd)) { - removedAgents.add(agent.id); - affectedWorkspaceCwds.add(agent.cwd); - affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd)); - try { - await this.agentManager.closeAgent(agent.id); - } catch { - // ignore cleanup errors - } - try { - await this.agentStorage.remove(agent.id); - } catch { - // ignore cleanup errors - } - } - } - - const registryRecords = await this.agentStorage.list(); - for (const record of registryRecords) { - if (this.isPathWithinRoot(targetPath, record.cwd)) { - removedAgents.add(record.id); - affectedWorkspaceCwds.add(record.cwd); - affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd)); - try { - await this.agentStorage.remove(record.id); - } catch { - // ignore cleanup errors - } - } - } - - await this.killTerminalsUnderPath(targetPath); - - await deletePaseoWorktree({ - cwd: options.repoRoot, - worktreePath: targetPath, - paseoHome: this.paseoHome, - }); - - for (const workspaceId of affectedWorkspaceIds) { - await this.archiveWorkspaceRecord(workspaceId); - } - - for (const agentId of removedAgents) { - this.emit({ - type: "agent_deleted", - payload: { - agentId, - requestId: options.requestId, - }, - }); - } - - await this.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds); - - return Array.from(removedAgents); + return handleWorktreeListRequest( + { + emit: (message) => this.emit(message), + paseoHome: this.paseoHome, + }, + msg, + ); } private async handlePaseoWorktreeArchiveRequest( msg: Extract, ): Promise { - const { requestId } = msg; - let targetPath = msg.worktreePath; - let repoRoot = msg.repoRoot ?? null; - - try { - if (!targetPath) { - if (!repoRoot || !msg.branchName) { - throw new Error("worktreePath or repoRoot+branchName is required"); - } - const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome }); - const match = worktrees.find((entry) => entry.branchName === msg.branchName); - if (!match) { - throw new Error(`Paseo worktree not found for branch ${msg.branchName}`); - } - targetPath = match.path; - } - - const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { + return handleWorktreeArchiveRequest( + { paseoHome: this.paseoHome, - }); - if (!ownership.allowed) { - this.emit({ - type: "paseo_worktree_archive_response", - payload: { - success: false, - removedAgents: [], - error: { - code: "NOT_ALLOWED", - message: "Worktree is not a Paseo-owned worktree", - }, - requestId, - }, - }); - return; - } - - repoRoot = ownership.repoRoot ?? repoRoot ?? null; - if (!repoRoot) { - throw new Error("Unable to resolve repo root for worktree"); - } - - const removedAgents = await this.archivePaseoWorktree({ - targetPath, - repoRoot, - requestId, - }); - - this.emit({ - type: "paseo_worktree_archive_response", - payload: { - success: true, - removedAgents, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "paseo_worktree_archive_response", - payload: { - success: false, - removedAgents: [], - error: toCheckoutError(error), - requestId, - }, - }); - } + agentManager: this.agentManager, + agentStorage: this.agentStorage, + archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + emit: (message) => this.emit(message), + emitWorkspaceUpdatesForCwds: (cwds) => this.emitWorkspaceUpdatesForCwds(cwds), + isPathWithinRoot: (rootPath, candidatePath) => + this.isPathWithinRoot(rootPath, candidatePath), + killTerminalsUnderPath: (rootPath) => this.killTerminalsUnderPath(rootPath), + }, + msg, + ); } /** @@ -5543,50 +5244,20 @@ export class Session { worktreePath: string; branchName: string; }): Promise { - const workspaceId = normalizePersistedWorkspaceId(options.worktreePath); - const basePlacement = await this.buildProjectPlacement(options.repoRoot); - const placement: ProjectPlacementPayload = { - ...basePlacement, - checkout: { - cwd: workspaceId, - isGit: true, - currentBranch: options.branchName, - remoteUrl: basePlacement.checkout.remoteUrl, - isPaseoOwnedWorktree: true, - mainRepoRoot: options.repoRoot, + return registerPendingWorktreeWorkspaceSession( + { + buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input), + 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), }, - }; - const now = new Date().toISOString(); - const existingWorkspace = await this.workspaceRegistry.get(workspaceId); - const existingProject = await this.projectRegistry.get(placement.projectKey); - const nextProjectRecord = this.buildPersistedProjectRecord({ - workspaceId, - placement, - createdAt: existingProject?.createdAt ?? now, - updatedAt: now, - }); - const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({ - workspaceId, - placement, - createdAt: existingWorkspace?.createdAt ?? now, - updatedAt: now, - }); - - await this.projectRegistry.upsert(nextProjectRecord); - await this.workspaceRegistry.upsert(nextWorkspaceRecord); - await this.syncWorkspaceGitWatchTarget(workspaceId, { - isGit: placement.checkout.isGit, - }); - - if ( - existingWorkspace && - !existingWorkspace.archivedAt && - existingWorkspace.projectId !== nextWorkspaceRecord.projectId - ) { - await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now); - } - - return nextWorkspaceRecord; + options, + ); } private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { @@ -5849,64 +5520,18 @@ export class Session { private async handleCreatePaseoWorktreeRequest( request: Extract, ): Promise { - try { - const checkout = await getCheckoutStatusLite(request.cwd, { paseoHome: this.paseoHome }); - if (!checkout.isGit) { - throw new Error("Create worktree requires a git repository"); - } - - const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd; - const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); - if (!baseBranch) { - throw new Error("Unable to resolve repository default branch"); - } - - const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4(); - const validation = validateBranchSlug(normalizedSlug); - if (!validation.valid) { - throw new Error(`Invalid worktree name: ${validation.error}`); - } - - const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, this.paseoHome); - const workspace = await this.registerPendingWorktreeWorkspace({ - repoRoot, - worktreePath, - branchName: normalizedSlug, - }); - const descriptor = await this.describeWorkspaceRecord(workspace); - this.emit({ - type: "create_paseo_worktree_response", - payload: { - workspace: descriptor, - error: null, - setupTerminalId: null, - requestId: request.requestId, - }, - }); - - void this.createPaseoWorktreeInBackground({ - requestCwd: request.cwd, - repoRoot, - baseBranch, - slug: normalizedSlug, - worktreePath, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create worktree"; - this.sessionLogger.error( - { err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug }, - "Failed to create worktree", - ); - this.emit({ - type: "create_paseo_worktree_response", - payload: { - workspace: null, - error: message, - setupTerminalId: null, - requestId: request.requestId, - }, - }); - } + return handleCreateWorktreeRequest( + { + paseoHome: this.paseoHome, + describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace), + emit: (message) => this.emit(message), + registerPendingWorktreeWorkspace: (options) => + this.registerPendingWorktreeWorkspace(options), + sessionLogger: this.sessionLogger, + createPaseoWorktreeInBackground: (options) => this.createPaseoWorktreeInBackground(options), + }, + request, + ); } private async createPaseoWorktreeInBackground(options: { @@ -5916,57 +5541,16 @@ export class Session { slug: string; worktreePath: string; }): Promise { - let setupTerminalId: string | null = null; - - try { - await createAgentWorktree({ - cwd: options.repoRoot, - branchName: options.slug, - baseBranch: options.baseBranch, - worktreeSlug: options.slug, + return createWorktreeInBackgroundSession( + { paseoHome: this.paseoHome, - }); - - const setupCommands = getWorktreeSetupCommands(options.worktreePath); - if (setupCommands.length > 0 && this.terminalManager) { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktreePath, - branchName: options.slug, - repoRootPath: options.repoRoot, - }); - this.terminalManager.registerCwdEnv({ - cwd: options.worktreePath, - env: runtimeEnv, - }); - const terminal = await this.terminalManager.createTerminal({ - cwd: options.worktreePath, - name: `setup-${options.slug}`, - env: runtimeEnv, - }); - setupTerminalId = terminal.id; - - for (const command of setupCommands) { - terminal.send({ - type: "input", - data: `${command}\r`, - }); - } - } - } catch (error) { - this.sessionLogger.error( - { - err: error, - cwd: options.requestCwd, - repoRoot: options.repoRoot, - worktreeSlug: options.slug, - worktreePath: options.worktreePath, - setupTerminalId, - }, - "Background worktree creation failed", - ); - } finally { - await this.emitWorkspaceUpdateForCwd(options.worktreePath); - } + emitWorkspaceUpdateForCwd: (cwd, emitOptions) => + this.emitWorkspaceUpdateForCwd(cwd, emitOptions), + sessionLogger: this.sessionLogger, + terminalManager: this.terminalManager, + }, + options, + ); } private async handleArchiveWorkspaceRequest( @@ -7868,36 +7452,16 @@ export class Session { } private async killTerminalsUnderPath(rootPath: string): Promise { - if (!this.terminalManager) { - return; - } - - const cleanupErrors: Array<{ cwd: string; message: string }> = []; - const terminalDirectories = [...this.terminalManager.listDirectories()]; - for (const terminalCwd of terminalDirectories) { - if (!this.isPathWithinRoot(rootPath, terminalCwd)) { - continue; - } - - try { - const terminals = await this.terminalManager.getTerminals(terminalCwd); - for (const terminal of [...terminals]) { - this.killTrackedTerminal(terminal.id, { emitExit: true }); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - cleanupErrors.push({ cwd: terminalCwd, message }); - this.sessionLogger.warn( - { err: error, cwd: terminalCwd }, - "Failed to clean up worktree terminals during archive", - ); - } - } - - if (cleanupErrors.length > 0) { - const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; "); - throw new Error(`Failed to clean up worktree terminals during archive (${details})`); - } + return killWorktreeTerminalsUnderPath( + { + isPathWithinRoot: (pathRoot, candidatePath) => + this.isPathWithinRoot(pathRoot, candidatePath), + killTrackedTerminal: (terminalId, options) => this.killTrackedTerminal(terminalId, options), + sessionLogger: this.sessionLogger, + terminalManager: this.terminalManager, + }, + rootPath, + ); } private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts new file mode 100644 index 000000000..2076c53e9 --- /dev/null +++ b/packages/server/src/server/worktree-session.ts @@ -0,0 +1,721 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { Logger } from "pino"; +import { v4 as uuidv4 } from "uuid"; + +import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import type { AgentManager } from "./agent/agent-manager.js"; +import type { AgentStorage } from "./agent/agent-storage.js"; +import { + type GitSetupOptions, + type ProjectPlacementPayload, + type SessionInboundMessage, + type SessionOutboundMessage, + type WorkspaceDescriptorPayload, +} from "./messages.js"; +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "./workspace-registry.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 { expandTilde } from "../utils/path.js"; +import { + computeWorktreePath, + deletePaseoWorktree, + getWorktreeSetupCommands, + isPaseoOwnedWorktreeCwd, + listPaseoWorktrees, + resolvePaseoWorktreeRootForCwd, + resolveWorktreeRuntimeEnv, + slugify, + validateBranchSlug, + type WorktreeConfig, +} from "../utils/worktree.js"; +import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js"; + +const execAsync = promisify(exec); +const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; + +export type NormalizedGitOptions = { + baseBranch?: string; + createNewBranch: boolean; + newBranchName?: string; + createWorktree: boolean; + worktreeSlug?: string; +}; + +type EmitSessionMessage = (message: SessionOutboundMessage) => void; + +type BuildAgentSessionConfigDependencies = { + paseoHome?: string; + sessionLogger: Logger; + checkoutExistingBranch: (cwd: string, branch: string) => Promise; + createBranchFromBase: (params: { + cwd: string; + baseBranch: string; + newBranchName: string; + }) => Promise; +}; + +type ArchivePaseoWorktreeDependencies = { + paseoHome?: string; + agentManager: Pick; + agentStorage: Pick; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + emit: EmitSessionMessage; + emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; + isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; + killTerminalsUnderPath: (rootPath: string) => Promise; +}; + +type RegisterPendingWorktreeWorkspaceDependencies = { + buildPersistedProjectRecord: (input: { + workspaceId: string; + placement: ProjectPlacementPayload; + createdAt: string; + updatedAt: string; + }) => PersistedProjectRecord; + buildPersistedWorkspaceRecord: (input: { + workspaceId: string; + placement: ProjectPlacementPayload; + createdAt: string; + updatedAt: string; + }) => PersistedWorkspaceRecord; + buildProjectPlacement: (cwd: string) => Promise; + projectRegistry: Pick; + syncWorkspaceGitWatchTarget: ( + cwd: string, + options: { isGit: boolean }, + ) => Promise; + workspaceRegistry: Pick; + archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise; +}; + +type CreatePaseoWorktreeInBackgroundDependencies = { + paseoHome?: string; + emitWorkspaceUpdateForCwd: ( + cwd: string, + options?: { dedupeGitState?: boolean }, + ) => Promise; + sessionLogger: Logger; + terminalManager: TerminalManager | null; +}; + +type HandleCreatePaseoWorktreeRequestDependencies = { + paseoHome?: string; + describeWorkspaceRecord: ( + workspace: PersistedWorkspaceRecord, + ) => Promise; + emit: EmitSessionMessage; + registerPendingWorktreeWorkspace: (options: { + repoRoot: string; + worktreePath: string; + branchName: string; + }) => Promise; + sessionLogger: Logger; + createPaseoWorktreeInBackground: (options: { + requestCwd: string; + repoRoot: string; + baseBranch: string; + slug: string; + worktreePath: string; + }) => Promise; +}; + +type KillTerminalsUnderPathDependencies = { + isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; + killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void; + sessionLogger: Logger; + terminalManager: TerminalManager | null; +}; + +export async function buildAgentSessionConfig( + dependencies: BuildAgentSessionConfigDependencies, + config: AgentSessionConfig, + gitOptions?: GitSetupOptions, + legacyWorktreeName?: string, + _labels?: Record, +): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> { + let cwd = expandTilde(config.cwd); + const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName); + let worktreeConfig: WorktreeConfig | undefined; + + if (!normalized) { + return { + sessionConfig: { + ...config, + cwd, + }, + }; + } + + if (normalized.createWorktree) { + let targetBranch: string; + + if (normalized.createNewBranch) { + targetBranch = normalized.newBranchName!; + } else { + const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + targetBranch = stdout.trim(); + } + + if (!targetBranch) { + throw new Error("A branch name is required when creating a worktree."); + } + + dependencies.sessionLogger.info( + { worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch }, + `Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`, + ); + + const baseBranch = + normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + const createdWorktree = await createAgentWorktree({ + branchName: targetBranch, + cwd, + baseBranch, + worktreeSlug: normalized.worktreeSlug ?? targetBranch, + paseoHome: dependencies.paseoHome, + }); + cwd = createdWorktree.worktreePath; + worktreeConfig = createdWorktree; + } else if (normalized.createNewBranch) { + const baseBranch = + normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + await dependencies.createBranchFromBase({ + cwd, + baseBranch, + newBranchName: normalized.newBranchName!, + }); + } else if (normalized.baseBranch) { + await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch); + } + + return { + sessionConfig: { + ...config, + cwd, + }, + worktreeConfig, + }; +} + +export function normalizeGitOptions( + gitOptions?: GitSetupOptions, + legacyWorktreeName?: string, +): NormalizedGitOptions | null { + const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName + ? { + createWorktree: true, + createNewBranch: true, + newBranchName: legacyWorktreeName, + worktreeSlug: legacyWorktreeName, + } + : undefined; + + const merged = gitOptions ?? fallbackOptions; + if (!merged) { + return null; + } + + const baseBranch = merged.baseBranch?.trim() || undefined; + const createWorktree = Boolean(merged.createWorktree); + const createNewBranch = Boolean(merged.createNewBranch); + const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined; + const normalizedWorktreeSlug = merged.worktreeSlug + ? slugify(merged.worktreeSlug) + : normalizedBranchName; + + if (!createWorktree && !createNewBranch && !baseBranch) { + return null; + } + + if (baseBranch) { + assertSafeGitRef(baseBranch, "base branch"); + } + + if (createNewBranch) { + if (!normalizedBranchName) { + throw new Error("New branch name is required"); + } + const validation = validateBranchSlug(normalizedBranchName); + if (!validation.valid) { + throw new Error(`Invalid branch name: ${validation.error}`); + } + } + + if (normalizedWorktreeSlug) { + const validation = validateBranchSlug(normalizedWorktreeSlug); + if (!validation.valid) { + throw new Error(`Invalid worktree name: ${validation.error}`); + } + } + + return { + baseBranch, + createNewBranch, + newBranchName: normalizedBranchName, + createWorktree, + worktreeSlug: normalizedWorktreeSlug, + }; +} + +export function assertSafeGitRef(ref: string, label: string): void { + if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) { + throw new Error(`Invalid ${label}: ${ref}`); + } +} + +export async function resolveGitCreateBaseBranch( + cwd: string, + paseoHome?: string, +): Promise { + const checkout = await getCheckoutStatusLite(cwd, { paseoHome }); + if (!checkout.isGit) { + throw new Error("Cannot create a worktree outside a git repository"); + } + + const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd; + const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); + if (!baseBranch) { + throw new Error("Unable to resolve repository default branch"); + } + return baseBranch; +} + +export async function handlePaseoWorktreeListRequest( + dependencies: { emit: EmitSessionMessage; paseoHome?: string }, + msg: Extract, +): Promise { + const { requestId } = msg; + const cwd = msg.repoRoot ?? msg.cwd; + if (!cwd) { + dependencies.emit({ + type: "paseo_worktree_list_response", + payload: { + worktrees: [], + error: { code: "UNKNOWN", message: "cwd or repoRoot is required" }, + requestId, + }, + }); + return; + } + + try { + const worktrees = await listPaseoWorktrees({ cwd, paseoHome: dependencies.paseoHome }); + dependencies.emit({ + type: "paseo_worktree_list_response", + payload: { + worktrees: worktrees.map((entry) => ({ + worktreePath: entry.path, + createdAt: entry.createdAt, + branchName: entry.branchName ?? null, + head: entry.head ?? null, + })), + error: null, + requestId, + }, + }); + } catch (error) { + dependencies.emit({ + type: "paseo_worktree_list_response", + payload: { + worktrees: [], + error: toCheckoutError(error), + requestId, + }, + }); + } +} + +export async function archivePaseoWorktree( + dependencies: ArchivePaseoWorktreeDependencies, + options: { + targetPath: string; + repoRoot: string; + requestId: string; + }, +): Promise { + let targetPath = options.targetPath; + const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { + paseoHome: dependencies.paseoHome, + }); + if (resolvedWorktree) { + targetPath = resolvedWorktree.worktreePath; + } + + const removedAgents = new Set(); + const affectedWorkspaceCwds = new Set([targetPath]); + const affectedWorkspaceIds = new Set([normalizePersistedWorkspaceId(targetPath)]); + const agents = dependencies.agentManager.listAgents(); + for (const agent of agents) { + if (!dependencies.isPathWithinRoot(targetPath, agent.cwd)) { + continue; + } + + removedAgents.add(agent.id); + affectedWorkspaceCwds.add(agent.cwd); + affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd)); + try { + await dependencies.agentManager.closeAgent(agent.id); + } catch { + // ignore cleanup errors + } + try { + await dependencies.agentStorage.remove(agent.id); + } catch { + // ignore cleanup errors + } + } + + const registryRecords = await dependencies.agentStorage.list(); + for (const record of registryRecords) { + if (!dependencies.isPathWithinRoot(targetPath, record.cwd)) { + continue; + } + + removedAgents.add(record.id); + affectedWorkspaceCwds.add(record.cwd); + affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd)); + try { + await dependencies.agentStorage.remove(record.id); + } catch { + // ignore cleanup errors + } + } + + await dependencies.killTerminalsUnderPath(targetPath); + + await deletePaseoWorktree({ + cwd: options.repoRoot, + worktreePath: targetPath, + paseoHome: dependencies.paseoHome, + }); + + for (const workspaceId of affectedWorkspaceIds) { + await dependencies.archiveWorkspaceRecord(workspaceId); + } + + for (const agentId of removedAgents) { + dependencies.emit({ + type: "agent_deleted", + payload: { + agentId, + requestId: options.requestId, + }, + }); + } + + await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds); + + return Array.from(removedAgents); +} + +export async function handlePaseoWorktreeArchiveRequest( + dependencies: Omit & { + emit: EmitSessionMessage; + emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; + }, + msg: Extract, +): Promise { + const { requestId } = msg; + let targetPath = msg.worktreePath; + let repoRoot = msg.repoRoot ?? null; + + try { + if (!targetPath) { + if (!repoRoot || !msg.branchName) { + throw new Error("worktreePath or repoRoot+branchName is required"); + } + const worktrees = await listPaseoWorktrees({ + cwd: repoRoot, + paseoHome: dependencies.paseoHome, + }); + const match = worktrees.find((entry) => entry.branchName === msg.branchName); + if (!match) { + throw new Error(`Paseo worktree not found for branch ${msg.branchName}`); + } + targetPath = match.path; + } + + const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { + paseoHome: dependencies.paseoHome, + }); + if (!ownership.allowed) { + dependencies.emit({ + type: "paseo_worktree_archive_response", + payload: { + success: false, + removedAgents: [], + error: { + code: "NOT_ALLOWED", + message: "Worktree is not a Paseo-owned worktree", + }, + requestId, + }, + }); + return; + } + + repoRoot = ownership.repoRoot ?? repoRoot ?? null; + if (!repoRoot) { + throw new Error("Unable to resolve repo root for worktree"); + } + + const removedAgents = await archivePaseoWorktree(dependencies, { + targetPath, + repoRoot, + requestId, + }); + + dependencies.emit({ + type: "paseo_worktree_archive_response", + payload: { + success: true, + removedAgents, + error: null, + requestId, + }, + }); + } catch (error) { + dependencies.emit({ + type: "paseo_worktree_archive_response", + payload: { + success: false, + removedAgents: [], + error: toCheckoutError(error), + requestId, + }, + }); + } +} + +export async function registerPendingWorktreeWorkspace( + dependencies: RegisterPendingWorktreeWorkspaceDependencies, + options: { + repoRoot: string; + worktreePath: string; + branchName: string; + }, +): Promise { + const workspaceId = normalizePersistedWorkspaceId(options.worktreePath); + const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot); + const placement: ProjectPlacementPayload = { + ...basePlacement, + checkout: { + cwd: workspaceId, + isGit: true, + currentBranch: options.branchName, + remoteUrl: basePlacement.checkout.remoteUrl, + isPaseoOwnedWorktree: true, + mainRepoRoot: options.repoRoot, + }, + }; + const now = new Date().toISOString(); + const existingWorkspace = await dependencies.workspaceRegistry.get(workspaceId); + const existingProject = await dependencies.projectRegistry.get(placement.projectKey); + const nextProjectRecord = dependencies.buildPersistedProjectRecord({ + workspaceId, + placement, + createdAt: existingProject?.createdAt ?? now, + updatedAt: now, + }); + const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({ + workspaceId, + placement, + createdAt: existingWorkspace?.createdAt ?? now, + updatedAt: now, + }); + + await dependencies.projectRegistry.upsert(nextProjectRecord); + await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord); + await dependencies.syncWorkspaceGitWatchTarget(workspaceId, { + isGit: placement.checkout.isGit, + }); + + if ( + existingWorkspace && + !existingWorkspace.archivedAt && + existingWorkspace.projectId !== nextWorkspaceRecord.projectId + ) { + await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now); + } + + return nextWorkspaceRecord; +} + +export async function handleCreatePaseoWorktreeRequest( + dependencies: HandleCreatePaseoWorktreeRequestDependencies, + request: Extract, +): Promise { + try { + const checkout = await getCheckoutStatusLite(request.cwd, { + paseoHome: dependencies.paseoHome, + }); + if (!checkout.isGit) { + throw new Error("Create worktree requires a git repository"); + } + + const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd; + const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); + if (!baseBranch) { + throw new Error("Unable to resolve repository default branch"); + } + + const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4(); + const validation = validateBranchSlug(normalizedSlug); + if (!validation.valid) { + throw new Error(`Invalid worktree name: ${validation.error}`); + } + + const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome); + const workspace = await dependencies.registerPendingWorktreeWorkspace({ + repoRoot, + worktreePath, + branchName: normalizedSlug, + }); + const descriptor = await dependencies.describeWorkspaceRecord(workspace); + dependencies.emit({ + type: "create_paseo_worktree_response", + payload: { + workspace: descriptor, + error: null, + setupTerminalId: null, + requestId: request.requestId, + }, + }); + + void dependencies.createPaseoWorktreeInBackground({ + requestCwd: request.cwd, + repoRoot, + baseBranch, + slug: normalizedSlug, + worktreePath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create worktree"; + dependencies.sessionLogger.error( + { err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug }, + "Failed to create worktree", + ); + dependencies.emit({ + type: "create_paseo_worktree_response", + payload: { + workspace: null, + error: message, + setupTerminalId: null, + requestId: request.requestId, + }, + }); + } +} + +export async function createPaseoWorktreeInBackground( + dependencies: CreatePaseoWorktreeInBackgroundDependencies, + options: { + requestCwd: string; + repoRoot: string; + baseBranch: string; + slug: string; + worktreePath: string; + }, +): Promise { + let setupTerminalId: string | null = null; + + try { + await createAgentWorktree({ + cwd: options.repoRoot, + branchName: options.slug, + baseBranch: options.baseBranch, + worktreeSlug: options.slug, + paseoHome: dependencies.paseoHome, + }); + + const setupCommands = getWorktreeSetupCommands(options.worktreePath); + if (setupCommands.length > 0 && dependencies.terminalManager) { + const runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: options.worktreePath, + branchName: options.slug, + repoRootPath: options.repoRoot, + }); + dependencies.terminalManager.registerCwdEnv({ + cwd: options.worktreePath, + env: runtimeEnv, + }); + const terminal = await dependencies.terminalManager.createTerminal({ + cwd: options.worktreePath, + name: `setup-${options.slug}`, + env: runtimeEnv, + }); + setupTerminalId = terminal.id; + + for (const command of setupCommands) { + terminal.send({ + type: "input", + data: `${command}\r`, + }); + } + } + } catch (error) { + dependencies.sessionLogger.error( + { + err: error, + cwd: options.requestCwd, + repoRoot: options.repoRoot, + worktreeSlug: options.slug, + worktreePath: options.worktreePath, + setupTerminalId, + }, + "Background worktree creation failed", + ); + } finally { + await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath); + } +} + +export async function killTerminalsUnderPath( + dependencies: KillTerminalsUnderPathDependencies, + rootPath: string, +): Promise { + if (!dependencies.terminalManager) { + return; + } + + const cleanupErrors: Array<{ cwd: string; message: string }> = []; + const terminalDirectories = [...dependencies.terminalManager.listDirectories()]; + for (const terminalCwd of terminalDirectories) { + if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) { + continue; + } + + try { + const terminals = await dependencies.terminalManager.getTerminals(terminalCwd); + for (const terminal of [...terminals]) { + dependencies.killTrackedTerminal(terminal.id, { emitExit: true }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + cleanupErrors.push({ cwd: terminalCwd, message }); + dependencies.sessionLogger.warn( + { err: error, cwd: terminalCwd }, + "Failed to clean up worktree terminals during archive", + ); + } + } + + if (cleanupErrors.length > 0) { + const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; "); + throw new Error(`Failed to clean up worktree terminals during archive (${details})`); + } +} diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 6ccf35980..de6df46b1 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -11,7 +11,9 @@ import { runWorktreeSetupCommands, slugify, } from "./worktree"; -import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; +import { + getPaseoWorktreeMetadataPath, +} from "./worktree-metadata.js"; import { execSync } from "child_process"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; import { dirname, join } from "path"; @@ -527,69 +529,106 @@ describe("paseo worktree manager", () => { expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false); }); - it("runs destroy commands from paseo.json before deleting a worktree", async () => { + it("runs teardown commands from paseo.json before deleting a worktree", async () => { const paseoConfig = { worktree: { - destroy: [ - 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', - 'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', - 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', - 'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', + teardown: [ + 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + 'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + 'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + 'echo "port=$PASEO_WORKTREE_PORT" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', ], }, }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'", { - cwd: repoDir, - }); + execSync( + "git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'", + { + cwd: repoDir, + }, + ); const created = await createWorktree({ - branchName: "destroy-branch", + branchName: "teardown-branch", cwd: repoDir, baseBranch: "main", - worktreeSlug: "destroy-test", + worktreeSlug: "teardown-test", paseoHome, }); + const runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: created.worktreePath, + branchName: created.branchName, + }); await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); expect(existsSync(created.worktreePath)).toBe(false); - const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8"); - expect(destroyLog).toContain(`source=${repoDir}`); - expect(destroyLog).toContain(`root_alias=${repoDir}`); - expect(destroyLog).toContain(`worktree=${created.worktreePath}`); - expect(destroyLog).toContain("branch=destroy-branch"); + const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8"); + expect(teardownLog).toContain(`source=${repoDir}`); + expect(teardownLog).toContain(`root_alias=${repoDir}`); + expect(teardownLog).toContain(`worktree=${created.worktreePath}`); + expect(teardownLog).toContain("branch=teardown-branch"); + expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`); }); - it("does not remove worktree when a destroy command fails", async () => { + it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => { const paseoConfig = { worktree: { - destroy: [ - 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"', + teardown: [ + 'echo "port=${PASEO_WORKTREE_PORT-unset}" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-port.log"', + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync( + "git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown port logging'", + { cwd: repoDir }, + ); + + const created = await createWorktree({ + branchName: "teardown-port-missing-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "teardown-port-missing-test", + paseoHome, + }); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); + + expect(readFileSync(join(repoDir, "teardown-port.log"), "utf8").trim()).toBe("port=unset"); + expect(existsSync(created.worktreePath)).toBe(false); + }); + + it("does not remove worktree when a teardown command fails", async () => { + const paseoConfig = { + worktree: { + teardown: [ + 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"', "echo boom 1>&2; exit 9", ], }, }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); execSync( - "git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing destroy commands'", + "git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing teardown commands'", { cwd: repoDir }, ); const created = await createWorktree({ - branchName: "destroy-failure-branch", + branchName: "teardown-failure-branch", cwd: repoDir, baseBranch: "main", - worktreeSlug: "destroy-failure-test", + worktreeSlug: "teardown-failure-test", paseoHome, }); await expect( deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }), - ).rejects.toThrow("Worktree destroy command failed"); + ).rejects.toThrow("Worktree teardown command failed"); expect(existsSync(created.worktreePath)).toBe(true); - expect(existsSync(join(repoDir, "destroy-start.log"))).toBe(true); + expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true); }); }); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 0103befc6..193901553 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -17,7 +17,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { worktree?: { setup?: string[]; - destroy?: string[]; + teardown?: string[]; terminals?: WorktreeTerminalConfig[]; }; } @@ -94,14 +94,14 @@ export class WorktreeSetupError extends Error { } } -export type WorktreeDestroyCommandResult = WorktreeSetupCommandResult; +export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult; -export class WorktreeDestroyError extends Error { - readonly results: WorktreeDestroyCommandResult[]; +export class WorktreeTeardownError extends Error { + readonly results: WorktreeTeardownCommandResult[]; - constructor(message: string, results: WorktreeDestroyCommandResult[]) { + constructor(message: string, results: WorktreeTeardownCommandResult[]) { super(message); - this.name = "WorktreeDestroyError"; + this.name = "WorktreeTeardownError"; this.results = results; } } @@ -150,13 +150,13 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] { return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); } -export function getWorktreeDestroyCommands(repoRoot: string): string[] { +export function getWorktreeTeardownCommands(repoRoot: string): string[] { const config = readPaseoConfig(repoRoot); - const destroyCommands = config?.worktree?.destroy; - if (!destroyCommands || destroyCommands.length === 0) { + const teardownCommands = config?.worktree?.teardown; + if (!teardownCommands || teardownCommands.length === 0) { return []; } - return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); + return teardownCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); } export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] { @@ -506,14 +506,14 @@ export async function resolveWorktreeRuntimeEnv(options: { }; } -export async function runWorktreeDestroyCommands(options: { +export async function runWorktreeTeardownCommands(options: { worktreePath: string; branchName?: string; repoRootPath?: string; -}): Promise { +}): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) - const destroyCommands = getWorktreeDestroyCommands(options.worktreePath); - if (destroyCommands.length === 0) { + const teardownCommands = getWorktreeTeardownCommands(options.worktreePath); + if (teardownCommands.length === 0) { return []; } @@ -521,30 +521,32 @@ export async function runWorktreeDestroyCommands(options: { options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); + const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); - const destroyEnv = { + const teardownEnv: NodeJS.ProcessEnv = { ...process.env, // Source checkout path is the original git repo root (shared across worktrees), not the - // worktree itself. This allows destroy scripts to clean resources using paths from the - // source checkout. + // worktree itself. This allows lifecycle scripts to copy or clean resources using paths + // from the source checkout. PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, // Backward-compatible alias. PASEO_ROOT_PATH: repoRootPath, PASEO_WORKTREE_PATH: options.worktreePath, PASEO_BRANCH_NAME: branchName, + ...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}), }; - const results: WorktreeDestroyCommandResult[] = []; - for (const cmd of destroyCommands) { + const results: WorktreeTeardownCommandResult[] = []; + for (const cmd of teardownCommands) { const result = await execSetupCommand(cmd, { cwd: options.worktreePath, - env: destroyEnv, + env: teardownEnv, }); results.push(result); if (result.exitCode !== 0) { - throw new WorktreeDestroyError( - `Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(), + throw new WorktreeTeardownError( + `Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(), results, ); } @@ -884,7 +886,7 @@ export async function deletePaseoWorktree({ throw new Error("Refusing to delete non-Paseo worktree"); } - await runWorktreeDestroyCommands({ + await runWorktreeTeardownCommands({ worktreePath: resolvedWorktree, }); diff --git a/packages/website/src/routes/docs/worktrees.tsx b/packages/website/src/routes/docs/worktrees.tsx index 18b02ed4a..17e959d95 100644 --- a/packages/website/src/routes/docs/worktrees.tsx +++ b/packages/website/src/routes/docs/worktrees.tsx @@ -96,7 +96,7 @@ function Worktrees() { {/* paseo.json */}
-

Setup with paseo.json

+

Lifecycle hooks with paseo.json

When Paseo creates a worktree, it's a fresh checkout. Dependencies aren't installed, config files aren't copied. You can automate setup by creating a{" "} @@ -117,6 +117,20 @@ function Worktrees() { the worktree is created. Use it to install dependencies, copy local config files, or run any other initialization.

+

+ You can also add a teardown array for cleanup commands + that run before Paseo removes the worktree directory during archive: +

+ +
{`{
+  "worktree": {
+    "teardown": [
+      "pkill -f \\"vite --port $PASEO_WORKTREE_PORT\\" || true",
+      "rm -rf \\"$PASEO_WORKTREE_PATH/.cache\\""
+    ]
+  }
+}`}
+
Important: Setup commands come from{" "} paseo.json in the selected base branch. If you pick{" "} @@ -130,7 +144,7 @@ function Worktrees() {

Environment variables

- Setup commands have access to these environment variables: + Setup and teardown commands have access to these environment variables:

  • @@ -148,8 +162,8 @@ function Worktrees() { $PASEO_BRANCH_NAME — the branch name created
  • - $PASEO_WORKTREE_PORT — an available local port for - setup scripts + $PASEO_WORKTREE_PORT — the worktree port, when + runtime metadata exists

@@ -157,6 +171,39 @@ function Worktrees() { shouldn't be in git (like .env) from your source checkout to the worktree.

+

+ $PASEO_WORKTREE_PORT is available when the worktree + was bootstrapped with a port. That makes it useful for both starting services in setup + and stopping them again in teardown. +

+
+ + {/* Teardown */} +
+

Teardown

+

+ Teardown runs during archive, before Paseo removes the worktree directory. Use it for + cleanup that needs access to the worktree path or its assigned port. +

+

+ Common uses include stopping dev servers on{" "} + $PASEO_WORKTREE_PORT, deleting generated files, or + deregistering services tied to that worktree. +

+ +
{`{
+  "worktree": {
+    "setup": [
+      "npm ci",
+      "nohup npm run dev -- --port $PASEO_WORKTREE_PORT > \\"$PASEO_WORKTREE_PATH/dev.log\\" 2>&1 &"
+    ],
+    "teardown": [
+      "pkill -f \\"npm run dev -- --port $PASEO_WORKTREE_PORT\\" || true",
+      "rm -f \\"$PASEO_WORKTREE_PATH/dev.log\\""
+    ]
+  }
+}`}
+
{/* Common patterns */} From 0d9bda12e620883efa12578b947e5f1539184c71 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 15:17:27 +0700 Subject: [PATCH 12/15] fix(app): centralize window controls padding into useWindowControlsPadding hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces useTrafficLightPadding with a role-based useWindowControlsPadding hook that absorbs all layout-conditional logic (sidebar state, focus mode, explorer state). Consumers no longer make platform or layout decisions — they just apply the resolved padding values. Fixes Windows title bar overlay buttons overlapping source control icons when the left sidebar is open, and right padding not shifting to the explorer sidebar header when it's open. Also fixes borderless header using borderBottomWidth:0 (layout shift) instead of transparent, and double-click-to-maximize bounce on the open project screen caused by nested drag handlers. --- .../app/src/components/explorer-sidebar.tsx | 4 +- .../src/components/headers/screen-header.tsx | 16 ++--- packages/app/src/components/left-sidebar.tsx | 8 +-- .../app/src/components/split-container.tsx | 11 +-- packages/app/src/constants/layout.ts | 11 --- .../app/src/screens/open-project-screen.tsx | 4 +- packages/app/src/utils/desktop-window.ts | 68 +++++++++++++++---- 7 files changed, 69 insertions(+), 53 deletions(-) diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 1499bfe03..50bbfda5d 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -17,6 +17,7 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "./git-diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useWindowControlsPadding } from "@/utils/desktop-window"; const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} @@ -336,12 +337,13 @@ function SidebarContent({ onOpenFile, }: SidebarContentProps) { const { theme } = useUnistyles(); + const padding = useWindowControlsPadding("explorerSidebar"); const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab; return ( {/* Header with tabs and close button */} - + {isGit && ( state.desktop.agentListOpen); - const trafficLightPadding = useTrafficLightPadding(); + const padding = useWindowControlsPadding("header"); // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const baseHorizontalPadding = theme.spacing[2]; - const collapsedSidebarInset = - !isMobile && !desktopAgentListOpen && trafficLightPadding.side - ? trafficLightPadding - : { left: 0, right: 0 }; const dragHandlers = useDesktopDragHandlers(); @@ -45,8 +39,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: style={[ styles.row, { - paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left, - paddingRight: baseHorizontalPadding + collapsedSidebarInset.right, + paddingLeft: baseHorizontalPadding + padding.left, + paddingRight: baseHorizontalPadding + padding.right, }, borderless && styles.borderless, ]} @@ -90,6 +84,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, borderless: { - borderBottomWidth: 0, + borderBottomColor: "transparent", }, })); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index bce074c7b..718575dda 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -35,7 +35,7 @@ import { type SidebarProjectEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; -import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window"; +import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window"; import { Combobox } from "@/components/ui/combobox"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { formatConnectionStatus } from "@/utils/daemons"; @@ -632,7 +632,7 @@ function DesktopSidebar({ }: DesktopSidebarProps) { const newAgentKeys = useShortcutKeys("new-agent"); const dragHandlers = useDesktopDragHandlers(); - const trafficLightPadding = useTrafficLightPadding(); + const padding = useWindowControlsPadding("sidebar"); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: viewportWidth } = useWindowDimensions(); @@ -682,9 +682,7 @@ function DesktopSidebar({ return ( - {trafficLightPadding.side === 'left' ? ( - - ) : null} + {padding.top > 0 ? : null} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 88f458407..b7c192f78 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -32,7 +32,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { usePanelStore } from "@/stores/panel-store"; -import { useTrafficLightPadding } from "@/utils/desktop-window"; +import { useWindowControlsPadding } from "@/utils/desktop-window"; import { computeTabDropPreview, type TabDropPreview, @@ -809,8 +809,7 @@ function SplitPaneView({ const { theme } = useUnistyles(); const paneRef = useRef(null); const stableOnFocusPane = useStableEvent(onFocusPane); - const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled); - const trafficLightPadding = useTrafficLightPadding(); + const padding = useWindowControlsPadding("tabRow"); const paneState = useMemo( () => deriveWorkspacePaneState({ @@ -885,11 +884,7 @@ function SplitPaneView({ + - + diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts index 5098ce2dd..15eeb4e3a 100644 --- a/packages/app/src/utils/desktop-window.ts +++ b/packages/app/src/utils/desktop-window.ts @@ -10,6 +10,7 @@ import { } from "@/constants/layout"; import { getDesktopWindow } from "@/desktop/electron/window"; import { isDesktop } from "@/desktop/host"; +import { usePanelStore } from "@/stores/panel-store"; import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates"; export async function toggleMaximize() { @@ -123,7 +124,15 @@ export function useDesktopDragHandlers(): DesktopDragViewProps { }, [isActive]); } -export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } { +type RawWindowControlsPadding = { + left: number; + right: number; + top: number; +}; + +type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar"; + +function useRawWindowControlsPadding(): RawWindowControlsPadding { const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { @@ -178,23 +187,52 @@ export function useTrafficLightPadding(): { left: number; right: number; top: nu }; }, []); - if (!getIsDesktop() || isFullscreen) { - return { left: 0, right: 0, top: 0, side: null }; - } + return useMemo((): RawWindowControlsPadding => { + if (!getIsDesktop() || isFullscreen) { + return { left: 0, right: 0, top: 0 }; + } + + if (getIsDesktopMac()) { + return { + left: DESKTOP_TRAFFIC_LIGHT_WIDTH, + right: 0, + top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, + }; + } - if (getIsDesktopMac()) { return { - left: DESKTOP_TRAFFIC_LIGHT_WIDTH, - right: 0, - top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, - side: 'left', + left: 0, + right: DESKTOP_WINDOW_CONTROLS_WIDTH, + top: DESKTOP_WINDOW_CONTROLS_HEIGHT, }; + }, [isFullscreen]); +} + +export function useWindowControlsPadding( + role: WindowControlsPaddingRole, +): { left: number; right: number; top: number } { + const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen); + const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); + const rawPadding = useRawWindowControlsPadding(); + const sidebarClosed = !sidebarOpen; + + let left = 0; + let right = 0; + let top = 0; + + if (role === "sidebar") { + left = rawPadding.left; + top = rawPadding.top; + } else if (role === "header") { + left = sidebarClosed ? rawPadding.left : 0; + right = explorerOpen ? 0 : rawPadding.right; + } else if (role === "tabRow") { + left = sidebarClosed && focusModeEnabled ? rawPadding.left : 0; + right = focusModeEnabled && !explorerOpen ? rawPadding.right : 0; + } else if (role === "explorerSidebar") { + right = rawPadding.right; } - return { - left: 0, - right: DESKTOP_WINDOW_CONTROLS_WIDTH, - top: DESKTOP_WINDOW_CONTROLS_HEIGHT, - side: 'right', - }; + return useMemo(() => ({ left, right, top }), [left, right, top]); } From e3b6e2dcfa2e4e84346c4d34cda895113a38e66f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 17:29:31 +0800 Subject: [PATCH 13/15] Create FUNDING.yml --- .github/FUNDING.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..706833347 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [boudra] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] From 5b22aedaff61ffcf8be52256a4c8142eff287f21 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 16:59:07 +0700 Subject: [PATCH 14/15] feat(app): polish file explorer tree with material icons and visual refinements Replace lucide file icons with colored SVGs from material-icon-theme covering 53 language/filetype-specific icons. Add chevron expand indicators for directories, indent guide lines, rounded rows, and restyle the header toolbar to match the git diff pane pattern. --- docs/FILE_ICONS.md | 109 ++++++++ package-lock.json | 103 ++++++++ packages/app/package.json | 1 + .../app/src/components/file-explorer-pane.tsx | 239 ++++++------------ .../app/src/components/material-file-icons.ts | 141 +++++++++++ 5 files changed, 438 insertions(+), 155 deletions(-) create mode 100644 docs/FILE_ICONS.md create mode 100644 packages/app/src/components/material-file-icons.ts diff --git a/docs/FILE_ICONS.md b/docs/FILE_ICONS.md new file mode 100644 index 000000000..96cedfb13 --- /dev/null +++ b/docs/FILE_ICONS.md @@ -0,0 +1,109 @@ +# File Icons + +The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`). + +Icons are inlined as SVG strings in: + +``` +packages/app/src/components/material-file-icons.ts +``` + +This file is auto-generated. Do not edit it by hand. + +## How it works + +- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings +- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names +- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon + +## Adding a new icon + +1. Find the icon name in the material-icon-theme manifest: + +```bash +node -e " +const m = require('./node_modules/material-icon-theme/dist/material-icons.json'); +console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']); +console.log('languageIds:', m.languageIds['YOUR_LANG']); +" +``` + +2. Verify the SVG exists: + +```bash +cat node_modules/material-icon-theme/icons/ICON_NAME.svg +``` + +3. Add two things to `material-file-icons.ts`: + + - The SVG string in `SVG_ICONS`: + ```ts + "icon_name": `...`, + ``` + + - The extension mapping in `EXTENSION_TO_ICON`: + ```ts + "ext": "icon_name", + ``` + +4. Run `npm run typecheck` to verify. + +## Currently included icons + +53 unique icons covering these extensions: + +| Extension(s) | Icon | +|---|---| +| `ts` | typescript | +| `tsx` | react_ts | +| `js` | javascript | +| `jsx` | react | +| `py` | python | +| `go` | go | +| `rs` | rust | +| `rb` | ruby | +| `java` | java | +| `kt` | kotlin | +| `c` | c | +| `cpp` | cpp | +| `h` | h | +| `hpp` | hpp | +| `cs` | csharp | +| `swift` | swift | +| `dart` | dart | +| `ex`, `exs` | elixir | +| `erl` | erlang | +| `hs` | haskell | +| `clj` | clojure | +| `scala` | scala | +| `ml` | ocaml | +| `r` | r | +| `lua` | lua | +| `zig` | zig | +| `nix` | nix | +| `php` | php | +| `html` | html | +| `css` | css | +| `scss` | sass | +| `less` | less | +| `json` | json | +| `yml`, `yaml` | yaml | +| `xml` | xml | +| `toml` | toml | +| `md`, `markdown` | markdown | +| `sql` | database | +| `graphql`, `gql` | graphql | +| `sh`, `bash` | console | +| `tf` | terraform | +| `hcl` | hcl | +| `vue` | vue | +| `svelte` | svelte | +| `astro` | astro | +| `wasm` | webassembly | +| `svg` | svg | +| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image | +| `txt` | document | +| `conf`, `cfg`, `ini` | settings | +| `lock` | lock | +| `groovy` | groovy | +| `gradle` | gradle | diff --git a/package-lock.json b/package-lock.json index c56fc40f0..04e42e33c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14831,6 +14831,13 @@ "node": ">=10" } }, + "node_modules/chroma-js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", + "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", + "dev": true, + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/chrome-launcher": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", @@ -15855,6 +15862,33 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-rename-keys": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz", + "integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2", + "rename-keys": "^1.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-rename-keys/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -18423,6 +18457,13 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==", + "dev": true, + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -25292,6 +25333,25 @@ "node": ">=10" } }, + "node_modules/material-icon-theme": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.32.0.tgz", + "integrity": "sha512-SxJxCcnk6cJIbd+AxmoeghXJ24joXGmUzjiGci16sX4mXZdXprGEzM6ZZ0VHGAofxNlMqznEbExINwFLsxf8eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chroma-js": "^3.1.2", + "events": "^3.3.0", + "fast-deep-equal": "^3.1.3", + "svgson": "^5.3.1" + }, + "engines": { + "vscode": "^1.55.0" + }, + "funding": { + "url": "https://github.com/sponsors/material-extensions" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -30156,6 +30216,16 @@ "dev": true, "license": "MIT" }, + "node_modules/rename-keys": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz", + "integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -32144,6 +32214,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svgson": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz", + "integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-rename-keys": "^0.2.1", + "xml-reader": "2.4.3" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -34550,6 +34631,16 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/xml-lexer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz", + "integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^2.0.0" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -34560,6 +34651,17 @@ "node": ">=12" } }, + "node_modules/xml-reader": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz", + "integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^2.0.0", + "xml-lexer": "^0.2.2" + } + }, "node_modules/xml2js": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", @@ -34947,6 +35049,7 @@ "eas-cli": "^16.24.1", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "material-icon-theme": "^5.32.0", "playwright": "^1.56.1", "typescript": "~5.9.2", "vitest": "^3.2.4", diff --git a/packages/app/package.json b/packages/app/package.json index c2790038b..97706dd58 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -111,6 +111,7 @@ "eas-cli": "^16.24.1", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "material-icon-theme": "^5.32.0", "playwright": "^1.56.1", "typescript": "~5.9.2", "vitest": "^3.2.4", diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index a030dc8b0..5c0632072 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -26,18 +26,17 @@ import Animated, { import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; +import { SvgXml } from "react-native-svg"; import { + ChevronDown, + ChevronRight, Copy, Download, - File, - FileText, - Folder, - FolderOpen, - Image as ImageIcon, MoreVertical, RotateCw, X, } from "lucide-react-native"; +import { getFileIconSvg } from "@/components/material-file-icons"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; @@ -65,7 +64,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: "size", label: "Size" }, ]; -const INDENT_PER_LEVEL = 12; +const INDENT_PER_LEVEL = 16; function formatFileSize({ size }: { size: number }): string { if (size < 1024) { @@ -384,7 +383,6 @@ export function FileExplorerPane({ ({ item }: ListRenderItemInfo) => { const entry = item.entry; const depth = item.depth; - const displayKind = getEntryDisplayKind(entry); const isDirectory = entry.kind === "directory"; const isExpanded = isDirectory && expandedPaths.has(entry.path); const isSelected = selectedEntryPath === entry.path; @@ -399,16 +397,30 @@ export function FileExplorerPane({ (hovered || pressed || isSelected) && styles.entryRowActive, ]} > + {depth > 0 && + Array.from({ length: depth }, (_, i) => ( + + ))} - {loading ? ( - + {isDirectory ? ( + loading ? ( + + ) : ( + + + + ) ) : ( - renderEntryIcon(isDirectory ? "directory" : displayKind, { - foreground: theme.colors.foregroundMuted, - primary: theme.colors.primary, - directoryOpen: isExpanded, - }) + )} @@ -552,27 +564,31 @@ export function FileExplorerPane({ ) : ( - - - [ - styles.iconButton, - (hovered || pressed) && styles.iconButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Refresh files" - > - - - - - - {currentSortLabel} - - + [ + styles.sortTrigger, + (hovered || pressed) && styles.sortTriggerHovered, + ]} + > + {currentSortLabel} + + + [ + styles.iconButton, + (hovered || pressed) && styles.iconButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Refresh files" + > + + + + - ) : ( - - ); - case "image": - return ; - case "text": - return ; - default: - return ; - } -} - -function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind { - if (entry.kind === "directory") { - return "directory"; - } - - const extension = getExtension(entry.name); - if (extension === null) { - return "other"; - } - - if (IMAGE_EXTENSIONS.has(extension)) { - return "image"; - } - - if (TEXT_EXTENSIONS.has(extension)) { - return "text"; - } - - return "other"; -} - -function getExtension(name: string): string | null { - const index = name.lastIndexOf("."); - if (index === -1 || index === name.length - 1) { - return null; - } - return name.slice(index + 1).toLowerCase(); -} - function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { const sorted = [...entries]; sorted.sort((a, b) => { @@ -842,50 +765,38 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, paneHeader: { + height: WORKSPACE_SECONDARY_HEADER_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "space-between", - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - paddingHorizontal: theme.spacing[3], + paddingRight: theme.spacing[3], borderBottomWidth: 1, borderBottomColor: theme.colors.border, - backgroundColor: theme.colors.surfaceSidebar, }, - paneHeaderLeft: { - flex: 1, - minWidth: 0, - }, - paneHeaderRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - previewHeaderRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - sortButton: { - height: 28, + sortTrigger: { flexDirection: "row", alignItems: "center", justifyContent: "center", - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, + gap: theme.spacing[1], + marginLeft: theme.spacing[3] - theme.spacing[1], + paddingHorizontal: theme.spacing[1], + height: 24, + borderRadius: theme.borderRadius.base, }, - sortButtonText: { - color: theme.colors.foregroundMuted, + sortTriggerHovered: { + backgroundColor: theme.colors.surface2, + }, + sortTriggerText: { fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, }, treeList: { flex: 1, minHeight: 0, }, entriesContent: { + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, centerState: { @@ -936,8 +847,16 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "space-between", paddingVertical: 2, paddingRight: theme.spacing[2], + borderRadius: theme.borderRadius.md, }, entryRowActive: { + backgroundColor: theme.colors.surface1, + }, + indentGuide: { + position: "absolute", + top: 0, + bottom: 0, + width: 1, backgroundColor: theme.colors.surface2, }, entryInfo: { @@ -947,6 +866,16 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], minWidth: 0, }, + chevron: { + width: 16, + height: 16, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, entryIcon: { flexShrink: 0, }, diff --git a/packages/app/src/components/material-file-icons.ts b/packages/app/src/components/material-file-icons.ts new file mode 100644 index 000000000..c683dbc84 --- /dev/null +++ b/packages/app/src/components/material-file-icons.ts @@ -0,0 +1,141 @@ +// Auto-generated from material-icon-theme. Do not edit manually. + +const SVG_ICONS: Record = { + "_default": ``, + "astro": ``, + "c": ``, + "clojure": ``, + "console": ``, + "cpp": ``, + "csharp": ``, + "css": ``, + "dart": ``, + "database": ``, + "document": ``, + "elixir": ``, + "erlang": ``, + "go": ``, + "gradle": ``, + "graphql": ``, + "groovy": ``, + "h": ``, + "haskell": ``, + "hcl": ``, + "hpp": ``, + "html": ``, + "image": ``, + "java": ``, + "javascript": ``, + "json": ``, + "kotlin": ``, + "less": ``, + "lock": ``, + "lua": ``, + "markdown": ``, + "nix": ``, + "ocaml": ``, + "php": ``, + "python": ``, + "r": ``, + "react": ``, + "react_ts": ``, + "ruby": ``, + "rust": ``, + "sass": ``, + "scala": ``, + "settings": ``, + "svelte": ``, + "svg": ``, + "swift": ``, + "terraform": ``, + "toml": ``, + "typescript": ``, + "vue": ``, + "webassembly": ``, + "xml": ``, + "yaml": ``, + "zig": ``, +}; + +const EXTENSION_TO_ICON: Record = { + "astro": "astro", + "bash": "console", + "c": "c", + "cfg": "settings", + "clj": "clojure", + "conf": "settings", + "cpp": "cpp", + "cs": "csharp", + "css": "css", + "dart": "dart", + "erl": "erlang", + "ex": "elixir", + "exs": "elixir", + "gif": "image", + "go": "go", + "gql": "graphql", + "gradle": "gradle", + "graphql": "graphql", + "groovy": "groovy", + "h": "h", + "hcl": "hcl", + "hpp": "hpp", + "hs": "haskell", + "html": "html", + "ico": "image", + "ini": "settings", + "java": "java", + "jpeg": "image", + "jpg": "image", + "js": "javascript", + "json": "json", + "jsx": "react", + "kt": "kotlin", + "less": "less", + "lock": "lock", + "lua": "lua", + "markdown": "markdown", + "md": "markdown", + "ml": "ocaml", + "nix": "nix", + "php": "php", + "png": "image", + "py": "python", + "r": "r", + "rb": "ruby", + "rs": "rust", + "scala": "scala", + "scss": "sass", + "sh": "console", + "sql": "database", + "svelte": "svelte", + "svg": "svg", + "swift": "swift", + "tf": "terraform", + "toml": "toml", + "ts": "typescript", + "tsx": "react_ts", + "txt": "document", + "vue": "vue", + "wasm": "webassembly", + "webp": "image", + "xml": "xml", + "yaml": "yaml", + "yml": "yaml", + "zig": "zig", +}; + +export function getFileIconSvg(fileName: string): string { + const ext = getExtension(fileName); + if (ext) { + const iconName = EXTENSION_TO_ICON[ext]; + if (iconName && SVG_ICONS[iconName]) return SVG_ICONS[iconName]; + } + return SVG_ICONS["_default"]; +} + +function getExtension(name: string): string | null { + const idx = name.lastIndexOf("."); + if (idx === -1 || idx === name.length - 1) return null; + return name.slice(idx + 1).toLowerCase(); +} From 0ba2f6b194d70cc146c190b52f8ff58bd878dc80 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 17:02:46 +0700 Subject: [PATCH 15/15] feat(cli): add `paseo terminal` command group for managing workspace terminals New CLI commands: ls, create, kill, capture (tmux capture-pane style with line range slicing and ANSI stripping), and send-keys (with special token interpretation). Server-side adds capture_terminal_request RPC and list-all-terminals support (optional CWD). Includes e2e tests and skill docs. --- packages/cli/src/cli.ts | 4 + packages/cli/src/commands/terminal/capture.ts | 99 +++++++++++ packages/cli/src/commands/terminal/create.ts | 41 +++++ packages/cli/src/commands/terminal/index.ts | 59 +++++++ packages/cli/src/commands/terminal/kill.ts | 51 ++++++ packages/cli/src/commands/terminal/ls.ts | 34 ++++ packages/cli/src/commands/terminal/schema.ts | 44 +++++ .../cli/src/commands/terminal/send-keys.ts | 99 +++++++++++ packages/cli/src/commands/terminal/shared.ts | 87 ++++++++++ packages/server/src/client/daemon-client.ts | 29 +++- .../server/daemon-e2e/terminal.e2e.test.ts | 162 ++++++++++++++++++ packages/server/src/server/session.ts | 92 +++++++++- packages/server/src/shared/messages.ts | 27 ++- packages/server/src/terminal/terminal.ts | 66 +++++++ skills/paseo/SKILL.md | 53 ++++++ 15 files changed, 938 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/commands/terminal/capture.ts create mode 100644 packages/cli/src/commands/terminal/create.ts create mode 100644 packages/cli/src/commands/terminal/index.ts create mode 100644 packages/cli/src/commands/terminal/kill.ts create mode 100644 packages/cli/src/commands/terminal/ls.ts create mode 100644 packages/cli/src/commands/terminal/schema.ts create mode 100644 packages/cli/src/commands/terminal/send-keys.ts create mode 100644 packages/cli/src/commands/terminal/shared.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1eb8a80fd..336d3015a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,6 +8,7 @@ import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; +import { createTerminalCommand } from "./commands/terminal/index.js"; import { createWorktreeCommand } from "./commands/worktree/index.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; @@ -143,6 +144,9 @@ export function createCli(): Command { // Chat commands program.addCommand(createChatCommand()); + // Terminal commands + program.addCommand(createTerminalCommand()); + // Loop commands program.addCommand(createLoopCommand()); diff --git a/packages/cli/src/commands/terminal/capture.ts b/packages/cli/src/commands/terminal/capture.ts new file mode 100644 index 000000000..2dc8f2180 --- /dev/null +++ b/packages/cli/src/commands/terminal/capture.ts @@ -0,0 +1,99 @@ +import type { Command } from "commander"; +import { renderError, toCommandError } from "../../output/render.js"; +import { + connectTerminalClient, + resolveTerminalId, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; + +export interface TerminalCaptureOptions extends TerminalCommandOptions { + start?: string; + end?: string; + scrollback?: boolean; + ansi?: boolean; +} + +export async function runCaptureCommand( + terminalId: string, + _options: TerminalCaptureOptions, + command: Command, +): Promise { + const options = command.optsWithGlobals() as TerminalCaptureOptions; + + try { + const payload = await executeCaptureCommand(terminalId, options); + if (options.json) { + process.stdout.write( + JSON.stringify( + { + terminalId: payload.terminalId, + lines: payload.lines, + totalLines: payload.totalLines, + }, + null, + 2, + ) + "\n", + ); + return; + } + + if (payload.lines.length > 0) { + process.stdout.write(payload.lines.join("\n") + "\n"); + } + } catch (err) { + const output = renderError(toCommandError(err), { + format: options.json ? "json" : "table", + noColor: options.color === false, + }); + process.stderr.write(output + "\n"); + process.exit(1); + } +} + +async function executeCaptureCommand( + terminalId: string, + options: TerminalCaptureOptions, +): Promise<{ terminalId: string; lines: string[]; totalLines: number }> { + const { client } = await connectTerminalClient(options.host); + + try { + const resolvedId = await resolveTerminalId(client, terminalId); + if (!resolvedId) { + throw { + code: "TERMINAL_NOT_FOUND", + message: `No terminal found matching: ${terminalId}`, + details: "Use `paseo terminal ls --all` to list available terminals.", + }; + } + + const start = options.scrollback ? 0 : parseLineNumber("--start", options.start); + const end = parseLineNumber("--end", options.end); + + return await client.captureTerminal(resolvedId, { + ...(start === undefined ? {} : { start }), + ...(end === undefined ? {} : { end }), + stripAnsi: !options.ansi, + }); + } catch (err) { + throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err); + } finally { + await client.close().catch(() => {}); + } +} + +function parseLineNumber(flag: string, value?: string): number | undefined { + if (value === undefined) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed)) { + throw { + code: "INVALID_LINE_NUMBER", + message: `Invalid ${flag} value: ${value}`, + details: "Use an integer line number.", + }; + } + return parsed; +} diff --git a/packages/cli/src/commands/terminal/create.ts b/packages/cli/src/commands/terminal/create.ts new file mode 100644 index 000000000..71e662052 --- /dev/null +++ b/packages/cli/src/commands/terminal/create.ts @@ -0,0 +1,41 @@ +import type { Command } from "commander"; +import type { SingleResult, CommandError } from "../../output/index.js"; +import { + connectTerminalClient, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; +import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; + +export interface TerminalCreateOptions extends TerminalCommandOptions { + cwd?: string; + name?: string; +} + +export async function runCreateCommand( + options: TerminalCreateOptions, + _command: Command, +): Promise> { + const { client } = await connectTerminalClient(options.host); + const cwd = options.cwd ?? process.cwd(); + + try { + const payload = await client.createTerminal(cwd, options.name); + if (!payload.terminal) { + const error: CommandError = { + code: "TERMINAL_CREATE_FAILED", + message: payload.error ?? "Failed to create terminal", + }; + throw error; + } + return { + type: "single", + data: toTerminalRow(payload.terminal), + schema: terminalSchema, + }; + } catch (err) { + throw toTerminalCommandError("TERMINAL_CREATE_FAILED", "create terminal", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/terminal/index.ts b/packages/cli/src/commands/terminal/index.ts new file mode 100644 index 000000000..0d0ddfd8b --- /dev/null +++ b/packages/cli/src/commands/terminal/index.ts @@ -0,0 +1,59 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addDaemonHostOption, addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { runCaptureCommand } from "./capture.js"; +import { runCreateCommand } from "./create.js"; +import { runKillCommand } from "./kill.js"; +import { runLsCommand } from "./ls.js"; +import { runSendKeysCommand } from "./send-keys.js"; + +export function createTerminalCommand(): Command { + const terminal = new Command("terminal").description("Manage workspace terminals"); + + addJsonAndDaemonHostOptions( + terminal + .command("ls") + .description("List terminals") + .option("--all", "List terminals across all workspaces") + .option("--cwd ", "Workspace directory"), + ).action(withOutput(runLsCommand)); + + addJsonAndDaemonHostOptions( + terminal + .command("create") + .description("Create a terminal") + .option("--cwd ", "Workspace directory") + .option("--name ", "Terminal name"), + ).action(withOutput(runCreateCommand)); + + addJsonAndDaemonHostOptions( + terminal + .command("kill") + .description("Kill a terminal") + .argument("", "Terminal ID, ID prefix, or name"), + ).action(withOutput(runKillCommand)); + + addDaemonHostOption( + terminal + .command("capture") + .description("Capture terminal output") + .argument("", "Terminal ID, ID prefix, or name") + .option("--start ", "Capture start line") + .option("--end ", "Capture end line") + .option("-S, --scrollback", "Capture from the beginning of scrollback") + .option("--ansi", "Preserve ANSI escape codes") + .option("--json", "Output in JSON format"), + ).action(runCaptureCommand); + + addDaemonHostOption( + terminal + .command("send-keys") + .description("Send keys to a terminal") + .argument("", "Terminal ID, ID prefix, or name") + .argument("", "Keys to send") + .option("-l, --literal", "Send raw keys without interpreting special tokens") + .option("--json", "Output in JSON format"), + ).action(runSendKeysCommand); + + return terminal; +} diff --git a/packages/cli/src/commands/terminal/kill.ts b/packages/cli/src/commands/terminal/kill.ts new file mode 100644 index 000000000..0a574e16d --- /dev/null +++ b/packages/cli/src/commands/terminal/kill.ts @@ -0,0 +1,51 @@ +import type { Command } from "commander"; +import type { CommandError, SingleResult } from "../../output/index.js"; +import { + connectTerminalClient, + resolveTerminalId, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; +import { terminalKillSchema, type TerminalKillRow } from "./schema.js"; + +export async function runKillCommand( + terminalId: string, + options: TerminalCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectTerminalClient(options.host); + + try { + const resolvedId = await requireTerminalId(client, terminalId); + const payload = await client.killTerminal(resolvedId); + return { + type: "single", + data: { + terminalId: payload.terminalId, + success: payload.success, + }, + schema: terminalKillSchema, + }; + } catch (err) { + throw toTerminalCommandError("TERMINAL_KILL_FAILED", "kill terminal", err); + } finally { + await client.close().catch(() => {}); + } +} + +async function requireTerminalId( + client: Awaited>["client"], + terminalId: string, +): Promise { + const resolvedId = await resolveTerminalId(client, terminalId); + if (resolvedId) { + return resolvedId; + } + + const error: CommandError = { + code: "TERMINAL_NOT_FOUND", + message: `No terminal found matching: ${terminalId}`, + details: "Use `paseo terminal ls --all` to list available terminals.", + }; + throw error; +} diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts new file mode 100644 index 000000000..7f47bee18 --- /dev/null +++ b/packages/cli/src/commands/terminal/ls.ts @@ -0,0 +1,34 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { + connectTerminalClient, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; +import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; + +export interface TerminalLsOptions extends TerminalCommandOptions { + all?: boolean; + cwd?: string; +} + +export async function runLsCommand( + options: TerminalLsOptions, + _command: Command, +): Promise> { + const { client } = await connectTerminalClient(options.host); + const cwd = options.all ? undefined : (options.cwd ?? process.cwd()); + + try { + const payload = cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd); + return { + type: "list", + data: payload.terminals.map((terminal) => toTerminalRow(terminal, payload.cwd ?? cwd)), + schema: terminalSchema, + }; + } catch (err) { + throw toTerminalCommandError("TERMINAL_LIST_FAILED", "list terminals", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/terminal/schema.ts b/packages/cli/src/commands/terminal/schema.ts new file mode 100644 index 000000000..9b777ad19 --- /dev/null +++ b/packages/cli/src/commands/terminal/schema.ts @@ -0,0 +1,44 @@ +import type { OutputSchema } from "../../output/index.js"; + +export interface TerminalRow { + id: string; + name: string; + cwd: string; +} + +export interface TerminalKillRow { + terminalId: string; + success: boolean; +} + +export const terminalSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "ID", field: (row) => row.id.slice(0, 8), width: 8 }, + { header: "NAME", field: "name", width: 24 }, + { header: "CWD", field: "cwd", width: 48 }, + ], +}; + +export const terminalKillSchema: OutputSchema = { + idField: "terminalId", + columns: [ + { header: "ID", field: (row) => row.terminalId.slice(0, 8), width: 8 }, + { header: "SUCCESS", field: "success", width: 8 }, + ], +}; + +export function toTerminalRow( + terminal: { + id: string; + name: string; + cwd?: string; + }, + cwd?: string, +): TerminalRow { + return { + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd ?? cwd ?? "-", + }; +} diff --git a/packages/cli/src/commands/terminal/send-keys.ts b/packages/cli/src/commands/terminal/send-keys.ts new file mode 100644 index 000000000..f109cece6 --- /dev/null +++ b/packages/cli/src/commands/terminal/send-keys.ts @@ -0,0 +1,99 @@ +import type { Command } from "commander"; +import { renderError, toCommandError } from "../../output/render.js"; +import { + connectTerminalClient, + resolveTerminalId, + toTerminalCommandError, + type TerminalCommandOptions, +} from "./shared.js"; + +export interface TerminalSendKeysOptions extends TerminalCommandOptions { + literal?: boolean; +} + +export async function runSendKeysCommand( + terminalId: string, + keys: string[], + _options: TerminalSendKeysOptions, + command: Command, +): Promise { + const options = command.optsWithGlobals() as TerminalSendKeysOptions; + + try { + const payload = await executeSendKeysCommand(terminalId, keys, options); + if (options.json) { + process.stdout.write(JSON.stringify(payload, null, 2) + "\n"); + } + } catch (err) { + const output = renderError(toCommandError(err), { + format: options.json ? "json" : "table", + noColor: options.color === false, + }); + process.stderr.write(output + "\n"); + process.exit(1); + } +} + +async function executeSendKeysCommand( + terminalId: string, + keys: string[], + options: TerminalSendKeysOptions, +): Promise<{ terminalId: string; keysSent: number }> { + const { client } = await connectTerminalClient(options.host); + + try { + const resolvedId = await resolveTerminalId(client, terminalId); + if (!resolvedId) { + throw { + code: "TERMINAL_NOT_FOUND", + message: `No terminal found matching: ${terminalId}`, + details: "Use `paseo terminal ls --all` to list available terminals.", + }; + } + + const data = keys.map((key) => resolveKeyToken(key, options.literal === true)).join(""); + client.sendTerminalInput(resolvedId, { type: "input", data }); + + return { + terminalId: resolvedId, + keysSent: data.length, + }; + } catch (err) { + throw toTerminalCommandError("TERMINAL_SEND_KEYS_FAILED", "send terminal keys", err); + } finally { + await client.close().catch(() => {}); + } +} + +function resolveKeyToken(key: string, literal: boolean): string { + if (literal) { + return key; + } + + switch (key) { + case "Enter": + return "\r"; + case "Tab": + return "\t"; + case "Escape": + return "\u001b"; + case "Space": + return " "; + case "BSpace": + return "\u007f"; + case "C-c": + return "\u0003"; + case "C-d": + return "\u0004"; + case "C-z": + return "\u001a"; + case "C-l": + return "\u000c"; + case "C-a": + return "\u0001"; + case "C-e": + return "\u0005"; + default: + return key; + } +} diff --git a/packages/cli/src/commands/terminal/shared.ts b/packages/cli/src/commands/terminal/shared.ts new file mode 100644 index 000000000..6e0493657 --- /dev/null +++ b/packages/cli/src/commands/terminal/shared.ts @@ -0,0 +1,87 @@ +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; + +export interface TerminalCommandOptions extends CommandOptions { + host?: string; +} + +interface TerminalLike { + id: string; + name?: string | null; +} + +export async function connectTerminalClient(host?: string) { + const daemonHost = getDaemonHost({ host }); + try { + const client = await connectToDaemon({ host }); + return { client, daemonHost }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${daemonHost}: ${message}`, + details: "Start the daemon with: paseo daemon start", + }; + throw error; + } +} + +export function toTerminalCommandError(code: string, action: string, err: unknown): CommandError { + if (err && typeof err === "object" && "code" in err && "message" in err) { + return err as CommandError; + } + + const message = err instanceof Error ? err.message : String(err); + const rpcCode = + typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" + ? err.code + : undefined; + + return { + code: rpcCode ?? code, + message: `Failed to ${action}: ${message}`, + }; +} + +export async function resolveTerminalId( + client: Awaited>, + idOrName: string, +): Promise { + const payload = await client.listTerminals(); + return resolveTerminalIdentifier(idOrName, payload.terminals); +} + +function resolveTerminalIdentifier(idOrName: string, terminals: TerminalLike[]): string | null { + if (!idOrName || terminals.length === 0) { + return null; + } + + const query = idOrName.toLowerCase(); + + const exactMatch = terminals.find((terminal) => terminal.id === idOrName); + if (exactMatch) { + return exactMatch.id; + } + + const prefixMatches = terminals.filter((terminal) => terminal.id.toLowerCase().startsWith(query)); + if (prefixMatches.length === 1 && prefixMatches[0]) { + return prefixMatches[0].id; + } + if (prefixMatches.length > 1) { + return null; + } + + const nameMatches = terminals.filter((terminal) => terminal.name?.toLowerCase() === query); + if (nameMatches.length === 1 && nameMatches[0]) { + return nameMatches[0].id; + } + + const partialNameMatches = terminals.filter((terminal) => + terminal.name?.toLowerCase().includes(query), + ); + if (partialNameMatches.length === 1 && partialNameMatches[0]) { + return partialNameMatches[0].id; + } + + return null; +} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 450fa05da..13d35ab3e 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -44,6 +44,7 @@ import type { SubscribeTerminalResponse, TerminalState, KillTerminalResponse, + CaptureTerminalResponse, TerminalInput, SessionInboundMessage, SessionOutboundMessage, @@ -237,6 +238,7 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; +type CaptureTerminalPayload = CaptureTerminalResponse["payload"]; type ChatCreatePayload = Extract< SessionOutboundMessage, { type: "chat/create/response" } @@ -2709,11 +2711,11 @@ export class DaemonClient { }); } - async listTerminals(cwd: string, requestId?: string): Promise { + async listTerminals(cwd?: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "list_terminals_request", - cwd, + ...(cwd === undefined ? {} : { cwd }), requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ @@ -2827,6 +2829,29 @@ export class DaemonClient { }); } + async captureTerminal( + terminalId: string, + options?: { start?: number; end?: number; stripAnsi?: boolean }, + requestId?: string, + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "capture_terminal_request", + terminalId, + ...(options?.start === undefined ? {} : { start: options.start }), + ...(options?.end === undefined ? {} : { end: options.end }), + ...(options?.stripAnsi === undefined ? {} : { stripAnsi: options.stripAnsi }), + requestId: resolvedRequestId, + }); + return this.sendCorrelatedRequest({ + requestId: resolvedRequestId, + message, + responseType: "capture_terminal_response", + timeout: 10000, + options: { skipQueue: true }, + }); + } + async createChatRoom(options: CreateChatRoomOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 24873b13b..0f66a29c8 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -415,12 +415,17 @@ async function subscribeRawTerminal( describe("daemon E2E terminal", () => { let ctx: DaemonTestContext; + let tempDirs: string[]; beforeEach(async () => { ctx = await createDaemonTestContext(); + tempDirs = []; }); afterEach(async () => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } await ctx.cleanup(); }, 60000); @@ -1055,4 +1060,161 @@ describe("daemon E2E terminal", () => { rmSync(cwd, { recursive: true, force: true }); } }, 40000); + + describe("capture", () => { + test("captures visible terminal output as plain text", async () => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo hello world\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("hello world"), 15000); + + const capture = await ctx.client.captureTerminal(terminalId); + + expect(capture.lines.join("\n")).toContain("hello world"); + expect(capture.totalLines).toBeGreaterThan(0); + }, 15000); + + test("captures with start/end line range", async () => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo line1\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line1"), 15000); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo line2\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line2"), 15000); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo line3\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line3"), 15000); + + const fullCapture = await ctx.client.captureTerminal(terminalId); + const rangedCapture = await ctx.client.captureTerminal(terminalId, { + start: 0, + end: 2, + }); + + expect(rangedCapture.lines).toHaveLength(3); + expect(rangedCapture.totalLines).toBe(fullCapture.totalLines); + }, 15000); + + test("supports negative line indices", async () => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo alpha\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("alpha"), 15000); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo beta\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("beta"), 15000); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo gamma\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("gamma"), 15000); + + const capture = await ctx.client.captureTerminal(terminalId, { + start: -3, + }); + + expect(capture.lines).toHaveLength(3); + }, 15000); + + test("strips ANSI by default", async () => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "printf '\\033[31mred text\\033[0m\\n'\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000); + + const capture = await ctx.client.captureTerminal(terminalId); + const capturedText = capture.lines.join("\n"); + + expect(capturedText).toContain("red text"); + expect(capturedText).not.toContain("\u001b[31m"); + }, 15000); + + test("returns empty for non-existent terminal", async () => { + const capture = await ctx.client.captureTerminal("terminal-does-not-exist"); + + expect(capture.lines).toEqual([]); + expect(capture.totalLines).toBe(0); + }); + }); + + describe("list terminals across directories", () => { + test("lists terminals from all directories when cwd is omitted", async () => { + const cwd1 = tmpCwd(); + const cwd2 = tmpCwd(); + tempDirs.push(cwd1, cwd2); + + const firstCreated = await ctx.client.createTerminal(cwd1, "first-terminal"); + const secondCreated = await ctx.client.createTerminal(cwd2, "second-terminal"); + + const list = await ctx.client.listTerminals(); + + expect(list).not.toHaveProperty("cwd"); + expect(list.terminals).toEqual( + expect.arrayContaining([ + { + id: firstCreated.terminal!.id, + name: "first-terminal", + }, + { + id: secondCreated.terminal!.id, + name: "second-terminal", + }, + ]), + ); + }); + + test("lists terminals for specific directory when cwd is provided", async () => { + const cwd1 = tmpCwd(); + const cwd2 = tmpCwd(); + tempDirs.push(cwd1, cwd2); + + const firstCreated = await ctx.client.createTerminal(cwd1, "cwd-one-terminal"); + await ctx.client.createTerminal(cwd2, "cwd-two-terminal"); + + const list = await ctx.client.listTerminals(cwd1); + + expect(list.cwd).toBe(cwd1); + expect(list.terminals).toEqual([ + { + id: firstCreated.terminal!.id, + name: "cwd-one-terminal", + }, + ]); + }); + }); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8b5ecbbd8..45bb25437 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -23,6 +23,7 @@ import { type UnsubscribeTerminalRequest, type TerminalInput, type KillTerminalRequest, + type CaptureTerminalRequest, type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, @@ -31,7 +32,7 @@ import { type WorkspaceStateBucket, } from "./messages.js"; import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js"; -import type { TerminalSession } from "../terminal/terminal.js"; +import { captureTerminalLines, type TerminalSession } from "../terminal/terminal.js"; import { TerminalStreamOpcode, encodeTerminalSnapshotPayload, @@ -1697,6 +1698,10 @@ export class Session { await this.handleKillTerminalRequest(msg); break; + case "capture_terminal_request": + await this.handleCaptureTerminalRequest(msg); + break; + case "chat/create": await this.handleChatCreateRequest(msg); break; @@ -7283,7 +7288,7 @@ export class Session { this.emit({ type: "list_terminals_response", payload: { - cwd: msg.cwd, + ...(msg.cwd ? { cwd: msg.cwd } : {}), terminals: [], requestId: msg.requestId, }, @@ -7292,14 +7297,17 @@ export class Session { } try { - const terminals = await this.terminalManager.getTerminals(msg.cwd); + const terminals = + typeof msg.cwd === "string" + ? await this.terminalManager.getTerminals(msg.cwd) + : await this.getAllTerminalSessions(); for (const terminal of terminals) { this.ensureTerminalExitSubscription(terminal); } this.emit({ type: "list_terminals_response", payload: { - cwd: msg.cwd, + ...(msg.cwd ? { cwd: msg.cwd } : {}), terminals: terminals.map((t) => ({ id: t.id, name: t.name })), requestId: msg.requestId, }, @@ -7309,7 +7317,7 @@ export class Session { this.emit({ type: "list_terminals_response", payload: { - cwd: msg.cwd, + ...(msg.cwd ? { cwd: msg.cwd } : {}), terminals: [], requestId: msg.requestId, }, @@ -7317,6 +7325,18 @@ export class Session { } } + private async getAllTerminalSessions(): Promise { + if (!this.terminalManager) { + return []; + } + + const directories = this.terminalManager.listDirectories(); + const terminalsByDirectory = await Promise.all( + directories.map((cwd) => this.terminalManager!.getTerminals(cwd)), + ); + return terminalsByDirectory.flat(); + } + private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ @@ -7488,6 +7508,68 @@ export class Session { }); } + private async handleCaptureTerminalRequest(msg: CaptureTerminalRequest): Promise { + if (!this.terminalManager) { + this.emit({ + type: "capture_terminal_response", + payload: { + terminalId: msg.terminalId, + lines: [], + totalLines: 0, + requestId: msg.requestId, + }, + }); + return; + } + + const session = this.terminalManager.getTerminal(msg.terminalId); + if (!session) { + this.emit({ + type: "capture_terminal_response", + payload: { + terminalId: msg.terminalId, + lines: [], + totalLines: 0, + requestId: msg.requestId, + }, + }); + return; + } + + this.ensureTerminalExitSubscription(session); + + try { + const capture = captureTerminalLines(session, { + start: msg.start, + end: msg.end, + stripAnsi: msg.stripAnsi, + }); + this.emit({ + type: "capture_terminal_response", + payload: { + terminalId: msg.terminalId, + lines: capture.lines, + totalLines: capture.totalLines, + requestId: msg.requestId, + }, + }); + } catch (error: any) { + this.sessionLogger.error( + { err: error, terminalId: msg.terminalId }, + "Failed to capture terminal", + ); + this.emit({ + type: "capture_terminal_response", + payload: { + terminalId: msg.terminalId, + lines: [], + totalLines: 0, + requestId: msg.requestId, + }, + }); + } + } + private bindActiveTerminalStream(terminal: TerminalSession): number | null { if (!this.onBinaryMessage) { return null; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 9885aaf27..84f771030 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1116,7 +1116,7 @@ export const RegisterPushTokenMessageSchema = z.object({ export const ListTerminalsRequestSchema = z.object({ type: z.literal("list_terminals_request"), - cwd: z.string(), + cwd: z.string().optional(), requestId: z.string(), }); @@ -1172,6 +1172,15 @@ export const KillTerminalRequestSchema = z.object({ requestId: z.string(), }); +export const CaptureTerminalRequestSchema = z.object({ + type: z.literal("capture_terminal_request"), + terminalId: z.string(), + start: z.number().int().optional(), + end: z.number().int().optional(), + stripAnsi: z.boolean().default(true), + requestId: z.string(), +}); + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ VoiceAudioChunkMessageSchema, AbortRequestMessageSchema, @@ -1235,6 +1244,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ UnsubscribeTerminalRequestSchema, TerminalInputSchema, KillTerminalRequestSchema, + CaptureTerminalRequestSchema, ChatCreateRequestSchema, ChatListRequestSchema, ChatInspectRequestSchema, @@ -2158,7 +2168,7 @@ export const TerminalStateSchema = z export const ListTerminalsResponseSchema = z.object({ type: z.literal("list_terminals_response"), payload: z.object({ - cwd: z.string(), + cwd: z.string().optional(), terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), requestId: z.string(), }), @@ -2207,6 +2217,16 @@ export const KillTerminalResponseSchema = z.object({ }), }); +export const CaptureTerminalResponseSchema = z.object({ + type: z.literal("capture_terminal_response"), + payload: z.object({ + terminalId: z.string(), + lines: z.array(z.string()), + totalLines: z.number().int().nonnegative(), + requestId: z.string(), + }), +}); + export const TerminalStreamExitSchema = z.object({ type: z.literal("terminal_stream_exit"), payload: z.object({ @@ -2276,6 +2296,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CreateTerminalResponseSchema, SubscribeTerminalResponseSchema, KillTerminalResponseSchema, + CaptureTerminalResponseSchema, TerminalStreamExitSchema, ChatCreateResponseSchema, ChatListResponseSchema, @@ -2468,6 +2489,8 @@ export type TerminalCursor = z.infer; export type TerminalState = z.infer; export type KillTerminalRequest = z.infer; export type KillTerminalResponse = z.infer; +export type CaptureTerminalRequest = z.infer; +export type CaptureTerminalResponse = z.infer; export type TerminalStreamExit = z.infer; // ============================================================================ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 28e2601c7..b6b80b302 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -4,6 +4,7 @@ import { randomUUID } from "crypto"; import { chmodSync, existsSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; +import stripAnsi from "strip-ansi"; import type { TerminalCell, TerminalState } from "../shared/messages.js"; const { Terminal } = xterm; @@ -40,6 +41,17 @@ export interface CreateTerminalOptions { name?: string; } +export interface CaptureTerminalLinesOptions { + start?: number; + end?: number; + stripAnsi?: boolean; +} + +export interface CaptureTerminalLinesResult { + lines: string[]; + totalLines: number; +} + type EnsureNodePtySpawnHelperExecutableOptions = { packageRoot?: string; platform?: NodeJS.Platform; @@ -230,6 +242,60 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] { }; } +function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string { + const text = cells.map((cell) => cell.char).join("").trimEnd(); + return options.stripAnsi ? stripAnsi(text) : text; +} + +function resolveCaptureLineIndex( + lineNumber: number | undefined, + totalLines: number, + fallback: "start" | "end", +): number { + if (totalLines === 0) { + return fallback === "start" ? 0 : -1; + } + + const defaultIndex = fallback === "start" ? 0 : totalLines - 1; + if (typeof lineNumber !== "number") { + return defaultIndex; + } + + const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber; + if (resolvedIndex < 0) { + return 0; + } + if (resolvedIndex >= totalLines) { + return totalLines - 1; + } + return resolvedIndex; +} + +export function captureTerminalLines( + terminal: TerminalSession, + options: CaptureTerminalLinesOptions = {}, +): CaptureTerminalLinesResult { + const state = terminal.getState(); + const allLines = [...state.scrollback, ...state.grid].map((cells) => + cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }), + ); + const totalLines = allLines.length; + const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start"); + const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end"); + + if (totalLines === 0 || startIndex > endIndex) { + return { + lines: [], + totalLines, + }; + } + + return { + lines: allLines.slice(startIndex, endIndex + 1), + totalLines, + }; +} + export async function createTerminal(options: CreateTerminalOptions): Promise { const { cwd, diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 711857266..1ac7355f2 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -154,6 +154,59 @@ paseo chat wait --timeout paseo chat delete ``` +## Terminal Commands + +Manage workspace terminals: create, inspect, send keystrokes, capture output. + +```bash +# List terminals (scoped to current directory by default) +paseo terminal ls # Terminals in current directory +paseo terminal ls --all # All terminals across all workspaces +paseo terminal ls --cwd ~/dev/myapp # Terminals in a specific directory + +# Create a terminal +paseo terminal create # In current directory +paseo terminal create --cwd ~/dev/myapp # In a specific directory +paseo terminal create --name "build-runner" # With a custom name + +# Kill a terminal (supports short ID prefixes and name matching) +paseo terminal kill +paseo terminal kill abc123 # Short prefix +paseo terminal kill build-runner # By name + +# Capture terminal output as plain text (like tmux capture-pane -p) +paseo terminal capture # Visible pane only, ANSI stripped +paseo terminal capture --scrollback # Full scrollback + visible +paseo terminal capture -S # Short form of --scrollback +paseo terminal capture --start 0 --end 10 # Line range (tmux-style) +paseo terminal capture --start -5 # Last 5 lines +paseo terminal capture --ansi # Preserve ANSI escape codes +paseo terminal capture --json # JSON output with metadata + +# Send keystrokes (like tmux send-keys) +paseo terminal send-keys "ls -la" Enter +paseo terminal send-keys "echo hello" Enter +paseo terminal send-keys C-c # Ctrl+C +paseo terminal send-keys C-d # Ctrl+D +paseo terminal send-keys --literal "raw text" # No special token interpretation +``` + +**Special key tokens** (interpreted by default, use `--literal` to send raw): +`Enter`, `Tab`, `Escape`, `Space`, `BSpace`, `C-c`, `C-d`, `C-z`, `C-l`, `C-a`, `C-e` + +**Common pattern — launch a process and interact with it:** +```bash +id=$(paseo terminal create --name "my-shell" -q) +paseo terminal send-keys "$id" "claude" Enter +sleep 5 +paseo terminal capture "$id" --scrollback # See what happened +paseo terminal send-keys "$id" "Hello!" Enter +sleep 10 +paseo terminal capture "$id" --scrollback # See the response +paseo terminal send-keys "$id" "/exit" Enter +paseo terminal kill "$id" +``` + ## Available Models **Claude (default provider)** — use aliases, CLI resolves to latest version: