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'] 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d763b1bd..b8cfc3cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # 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 +- 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 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/nix/package.nix b/nix/package.nix index cd1c33592..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-odgbFOAjAsBTnfKu6RJ3PEgiYnvIXBLtkdkaHtIRPyw="; + 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). @@ -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/package-lock.json b/package-lock.json index 2591e369a..ed622b5ac 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": [ @@ -15364,6 +15364,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", @@ -16386,6 +16393,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", @@ -19579,6 +19613,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", @@ -26475,6 +26516,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", @@ -31390,6 +31450,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", @@ -33421,6 +33491,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", @@ -35873,6 +35954,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", @@ -35883,6 +35974,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", @@ -36183,16 +36285,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", @@ -36270,6 +36372,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", @@ -36308,11 +36411,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", @@ -36353,11 +36456,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" @@ -36391,7 +36494,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", @@ -36592,7 +36695,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", @@ -36618,7 +36721,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.37", + "version": "0.1.38", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -36634,13 +36737,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", @@ -37042,7 +37145,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 614881dda..adb9371fb 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..97706dd58 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", @@ -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/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 && ( ) => { 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/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 5cfb6dd48..420c950b8 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -7,8 +7,7 @@ import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE, } from "@/constants/layout"; -import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window"; -import { usePanelStore } from "@/stores/panel-store"; +import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window"; interface ScreenHeaderProps { left?: ReactNode; @@ -26,15 +25,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const desktopAgentListOpen = usePanelStore((state) => 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 dfe51a89e..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} @@ -811,7 +809,6 @@ const styles = StyleSheet.create((theme) => ({ sidebarContent: { flex: 1, minHeight: 0, - overflow: "hidden", }, desktopSidebar: { position: "relative", 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(); +} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index c65c0a8fb..e1fcec959 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, @@ -792,8 +792,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({ @@ -868,11 +867,7 @@ function SplitPaneView({ { 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/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index a62755afa..4662ae816 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -26,9 +26,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { }, [isMobile, openAgentList]); return ( - + - + 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]); } 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/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/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/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/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/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/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/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/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 1e7107191..416630e42 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 480e7bb4a..feef0a1cf 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": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.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/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..73934b0c2 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, PASEO_SUPERVISED: "1" }; 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/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index a4bb3e7bc..0488d1b8f 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -39,13 +39,12 @@ import type { ListCommandsResponse, ListProviderModelsResponseMessage, ListAvailableProvidersResponse, - SpeechModelsListResponse, - SpeechModelsDownloadResponse, ListTerminalsResponse, CreateTerminalResponse, SubscribeTerminalResponse, TerminalState, KillTerminalResponse, + CaptureTerminalResponse, TerminalInput, SessionInboundMessage, SessionOutboundMessage, @@ -216,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, @@ -240,6 +237,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" } @@ -2485,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( @@ -2737,11 +2709,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({ @@ -2859,6 +2831,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/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2ccbccbb6..43764d79c 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 type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; @@ -116,7 +116,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, @@ -187,10 +186,6 @@ export type PaseoDaemonConfig = { downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; - pidLock?: { - mode?: "self" | "external"; - ownerPid?: number; - }; }; export interface PaseoDaemon { @@ -459,7 +454,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 => { @@ -622,21 +616,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, @@ -648,7 +633,7 @@ export async function createPaseoDaemon( config.paseoHome, createInMemoryAgentMcpTransport, { allowedOrigins, allowedHosts: config.allowedHosts }, - { turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts }, + speechService, terminalManager, { voiceAgentMcpStdio: { @@ -666,9 +651,6 @@ export async function createPaseoDaemon( }, { finalTimeoutMs: config.dictationFinalTimeoutMs, - stt: resolveDictationStt, - localModels: localModelConfig ?? undefined, - getSpeechReadiness, }, config.agentProviderSettings, daemonVersion, @@ -686,9 +668,6 @@ export async function createPaseoDaemon( scheduleService, checkoutDiffManager, ); - unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => { - wsServer?.publishSpeechReadiness(snapshot); - }); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); @@ -724,6 +703,16 @@ export async function createPaseoDaemon( ); } + if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") { + process.send({ + type: "paseo:ready", + listen: + boundListenTarget.type === "tcp" + ? `${boundListenTarget.host}:${boundListenTarget.port}` + : boundListenTarget.path, + }); + } + if (relayEnabled) { const offer = await createConnectionOfferV2({ serverId, @@ -764,6 +753,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 () => { @@ -773,9 +766,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) { @@ -792,12 +783,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 { 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/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index cb795c340..0ba3e9e5b 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); @@ -1090,4 +1095,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/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 0cf34d03b..a7a9915ef 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, @@ -122,22 +123,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, @@ -145,7 +136,6 @@ import { pushCurrentBranch, createPullRequest, getPullRequestStatus, - resolveRepositoryDefaultBranch, } from "../utils/checkout-git.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; @@ -156,12 +146,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"; @@ -173,6 +158,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); @@ -239,14 +234,6 @@ type WorkspaceGitWatchTarget = { latestFingerprint: string | null; }; -type NormalizedGitOptions = { - baseBranch?: string; - createNewBranch: boolean; - newBranchName?: string; - createWorktree: boolean; - worktreeSlug?: string; -}; - type ActiveTerminalStream = { terminalId: string; slot: number; @@ -331,7 +318,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; @@ -398,10 +384,6 @@ export type SessionOptions = { dictation?: { finalTimeoutMs?: number; stt?: Resolvable; - localModels?: { - modelsDir: string; - defaultModelIds: LocalSpeechModelId[]; - }; getSpeechReadiness?: () => SpeechReadinessSnapshot; }; agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; @@ -570,8 +552,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, @@ -656,15 +636,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; @@ -1339,7 +1310,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": @@ -1563,14 +1537,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; @@ -1633,6 +1599,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; @@ -2770,70 +2740,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( @@ -2900,182 +2818,8 @@ 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, - ): 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 { @@ -4317,194 +4061,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 agents = this.agentManager.listAgents(); - for (const agent of agents) { - if (this.isPathWithinRoot(targetPath, agent.cwd)) { - removedAgents.add(agent.id); - affectedWorkspaceCwds.add(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); - 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 workspaceCwd of affectedWorkspaceCwds) { - const workspace = await this.findWorkspaceByDirectory(workspaceCwd); - if (!workspace) { - continue; - } - await this.archiveWorkspaceRecord(workspace.id); - } - - 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, + ); } /** @@ -5536,65 +5118,20 @@ export class Session { worktreePath: string; branchName: string; }): Promise { - await this.findOrCreateWorkspaceForDirectory(options.repoRoot); - const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath); - const basePlacement = await this.buildProjectPlacementForCwd(options.repoRoot); - if (!basePlacement) { - throw new Error(`Workspace not found for repo root ${options.repoRoot}`); - } - - const projectId = Number(basePlacement.projectKey); - if (!Number.isInteger(projectId)) { - throw new Error(`Invalid project id for repo root ${options.repoRoot}`); - } - - const now = new Date().toISOString(); - const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory); - if (!existingWorkspace) { - const workspaceId = await this.workspaceRegistry.insert({ - projectId, - directory: workspaceDirectory, - displayName: options.branchName, - kind: "worktree", - createdAt: now, - updatedAt: now, - archivedAt: null, - }); - const workspace = await this.workspaceRegistry.get(workspaceId); - if (!workspace) { - throw new Error(`Workspace not found after insert: ${workspaceId}`); - } - await this.syncWorkspaceGitWatchTarget(workspace.directory, { isGit: true }); - return workspace; - } - - const nextWorkspaceRecord = createPersistedWorkspaceRecord({ - id: existingWorkspace.id, - projectId, - directory: workspaceDirectory, - displayName: options.branchName, - kind: "worktree", - createdAt: existingWorkspace.createdAt, - updatedAt: now, - archivedAt: null, - }); - - await this.workspaceRegistry.upsert(nextWorkspaceRecord); - await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); - - if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) { - const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => - workspace.projectId === existingWorkspace.projectId && - workspace.id !== existingWorkspace.id && - !workspace.archivedAt, - ); - if (siblingWorkspaces.length === 0) { - await this.projectRegistry.archive(existingWorkspace.projectId, now); - } - } - - return nextWorkspaceRecord; + 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), + }, + options, + ); } private async archiveWorkspaceRecord(workspaceId: number, archivedAt?: string): Promise { @@ -5858,105 +5395,37 @@ 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); - await createAgentWorktree({ - cwd: repoRoot, - branchName: normalizedSlug, - baseBranch, - worktreeSlug: normalizedSlug, + 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, + ); + } - let setupTerminalId: string | null = null; - try { - const setupCommands = getWorktreeSetupCommands(worktreePath); - if (setupCommands.length > 0 && this.terminalManager) { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath, - branchName: normalizedSlug, - repoRootPath: repoRoot, - }); - this.terminalManager.registerCwdEnv({ - cwd: worktreePath, - env: runtimeEnv, - }); - const terminal = await this.terminalManager.createTerminal({ - cwd: worktreePath, - name: `setup-${normalizedSlug}`, - 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: request.cwd, - repoRoot, - worktreeSlug: normalizedSlug, - worktreePath, - }, - "Worktree setup terminal initialization failed", - ); - } - - const workspace = await this.registerWorktreeWorkspaceRecord({ - repoRoot, - worktreePath, - branchName: normalizedSlug, - }); - await this.emitWorkspaceUpdateForCwd(worktreePath); - const descriptor = await this.describeWorkspaceRecord(workspace); - this.emit({ - type: "create_paseo_worktree_response", - payload: { - workspace: descriptor, - error: null, - setupTerminalId, - requestId: request.requestId, - }, - }); - } 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, - }, - }); - } + private async createPaseoWorktreeInBackground(options: { + requestCwd: string; + repoRoot: string; + baseBranch: string; + slug: string; + worktreePath: string; + }): Promise { + return createWorktreeInBackgroundSession( + { + paseoHome: this.paseoHome, + emitWorkspaceUpdateForCwd: (cwd, emitOptions) => + this.emitWorkspaceUpdateForCwd(cwd, emitOptions), + sessionLogger: this.sessionLogger, + terminalManager: this.terminalManager, + }, + options, + ); } private async handleArchiveWorkspaceRequest( @@ -6922,7 +6391,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()) && @@ -7637,7 +7109,7 @@ export class Session { this.emit({ type: "list_terminals_response", payload: { - cwd: msg.cwd, + ...(msg.cwd ? { cwd: msg.cwd } : {}), terminals: [], requestId: msg.requestId, }, @@ -7647,7 +7119,9 @@ export class Session { try { const terminals = this.filterStandaloneTerminals( - await this.terminalManager.getTerminals(msg.cwd), + typeof msg.cwd === "string" + ? await this.terminalManager.getTerminals(msg.cwd) + : await this.getAllTerminalSessions(), ); for (const terminal of terminals) { this.ensureTerminalExitSubscription(terminal); @@ -7655,7 +7129,7 @@ export class Session { this.emit({ type: "list_terminals_response", payload: { - cwd: msg.cwd, + ...(msg.cwd ? { cwd: msg.cwd } : {}), terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), requestId: msg.requestId, }, @@ -7665,7 +7139,7 @@ export class Session { this.emit({ type: "list_terminals_response", payload: { - cwd: msg.cwd, + ...(msg.cwd ? { cwd: msg.cwd } : {}), terminals: [], requestId: msg.requestId, }, @@ -7713,6 +7187,18 @@ export class Session { return terminal; } + 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({ @@ -7874,36 +7360,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 { @@ -7930,6 +7396,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/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 d666c97a3..eb65bfea8 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, @@ -244,18 +240,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; @@ -289,6 +277,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, @@ -300,11 +289,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; @@ -313,12 +298,6 @@ export class VoiceAssistantWebSocketServer { }, dictation?: { finalTimeoutMs?: number; - stt?: Resolvable; - localModels?: { - modelsDir: string; - defaultModelIds: LocalSpeechModelId[]; - }; - getSpeechReadiness?: () => SpeechReadinessSnapshot; }, agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, daemonVersion?: string, @@ -359,17 +338,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")); @@ -460,6 +440,8 @@ export class VoiceAssistantWebSocketServer { } public async close(): Promise { + this.unsubscribeSpeechReadiness?.(); + this.unsubscribeSpeechReadiness = null; if (this.runtimeMetricsInterval) { clearInterval(this.runtimeMetricsInterval); this.runtimeMetricsInterval = null; @@ -661,12 +643,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) => { @@ -684,7 +666,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/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/shared/messages.ts b/packages/server/src/shared/messages.ts index 6e510f34d..d142eae1a 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -739,17 +739,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, @@ -1141,7 +1130,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(), }); @@ -1200,6 +1189,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, @@ -1220,8 +1218,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CreateAgentRequestMessageSchema, ListProviderModelsRequestMessageSchema, ListAvailableProvidersRequestMessageSchema, - SpeechModelsListRequestSchema, - SpeechModelsDownloadRequestSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, CancelAgentRequestMessageSchema, @@ -1265,6 +1261,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ UnsubscribeTerminalRequestSchema, TerminalInputSchema, KillTerminalRequestSchema, + CaptureTerminalRequestSchema, ChatCreateRequestSchema, ChatListRequestSchema, ChatInspectRequestSchema, @@ -2112,34 +2109,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(), @@ -2209,7 +2178,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(), }), @@ -2258,6 +2227,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({ @@ -2321,14 +2300,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FileDownloadTokenResponseSchema, ListProviderModelsResponseMessageSchema, ListAvailableProvidersResponseSchema, - SpeechModelsListResponseSchema, - SpeechModelsDownloadResponseSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, CreateTerminalResponseSchema, SubscribeTerminalResponseSchema, KillTerminalResponseSchema, + CaptureTerminalResponseSchema, TerminalStreamExitSchema, ChatCreateResponseSchema, ChatListResponseSchema, @@ -2391,8 +2369,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; @@ -2453,8 +2429,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; @@ -2525,6 +2499,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 491523228..dc5813c0d 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -5,6 +5,7 @@ import { chmodSync, existsSync, statSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; +import stripAnsi from "strip-ansi"; import type { TerminalCell, TerminalState } from "../shared/messages.js"; const { Terminal } = xterm; @@ -62,6 +63,17 @@ interface BuildTerminalEnvironmentInput { env: Record; } +export interface CaptureTerminalLinesOptions { + start?: number; + end?: number; + stripAnsi?: boolean; +} + +export interface CaptureTerminalLinesResult { + lines: string[]; + totalLines: number; +} + type EnsureNodePtySpawnHelperExecutableOptions = { packageRoot?: string; platform?: NodeJS.Platform; @@ -426,6 +438,60 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] { return lines.slice(-limit); } +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/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/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"] } 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": { 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 ( 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 */} 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: