Merge branch 'main' into storage-terminal-ui-dev

This commit is contained in:
Mohamed Boudra
2026-03-30 17:14:03 +07:00
70 changed files with 2801 additions and 1443 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -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']

View File

@@ -9,6 +9,8 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:

View File

@@ -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

109
docs/FILE_ICONS.md Normal file
View File

@@ -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": `<svg ...>...</svg>`,
```
- 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 |

View File

@@ -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/

141
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.37",
"version": "0.1.38",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

View File

@@ -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",

View File

@@ -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<string, unknown>): 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 (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={styles.header} testID="explorer-header">
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<View style={styles.tabsContainer}>
{isGit && (
<Pressable

View File

@@ -26,18 +26,17 @@ import Animated, {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
ChevronRight,
Copy,
Download,
File,
FileText,
Folder,
FolderOpen,
Image as ImageIcon,
MoreVertical,
RotateCw,
X,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -65,7 +64,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "size", label: "Size" },
];
const INDENT_PER_LEVEL = 12;
const INDENT_PER_LEVEL = 16;
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
@@ -384,7 +383,6 @@ export function FileExplorerPane({
({ item }: ListRenderItemInfo<TreeRow>) => {
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) => (
<View
key={i}
style={[
styles.indentGuide,
{
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
},
]}
/>
))}
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{loading ? (
<ActivityIndicator size="small" />
{isDirectory ? (
loading ? (
<ActivityIndicator size="small" />
) : (
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
)
) : (
renderEntryIcon(isDirectory ? "directory" : displayKind, {
foreground: theme.colors.foregroundMuted,
primary: theme.colors.primary,
directoryOpen: isExpanded,
})
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
@@ -552,27 +564,31 @@ export function FileExplorerPane({
) : (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
<Pressable
onPress={handleSortCycle}
style={({ hovered, pressed }) => [
styles.sortTrigger,
(hovered || pressed) && styles.sortTriggerHovered,
]}
>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
</View>
<FlatList
ref={treeListRef}
@@ -609,99 +625,6 @@ export function FileExplorerPane({
);
}
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
"md",
"markdown",
"ts",
"tsx",
"js",
"jsx",
"json",
"yml",
"yaml",
"toml",
"py",
"rb",
"go",
"rs",
"java",
"kt",
"c",
"cpp",
"cc",
"h",
"hpp",
"cs",
"swift",
"php",
"html",
"css",
"scss",
"less",
"xml",
"sh",
"bash",
"zsh",
"ini",
"cfg",
"conf",
]);
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
case "directory":
return colors.directoryOpen ? (
<FolderOpen size={18} color={colors.primary} />
) : (
<Folder size={18} color={colors.primary} />
);
case "image":
return <ImageIcon size={18} color={color} />;
case "text":
return <FileText size={18} color={color} />;
default:
return <File size={18} color={color} />;
}
}
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,
},

View File

@@ -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",
},
}));

View File

@@ -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 (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.side === 'left' ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
@@ -811,7 +809,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
},
desktopSidebar: {
position: "relative",

View File

@@ -0,0 +1,141 @@
// Auto-generated from material-icon-theme. Do not edit manually.
const SVG_ICONS: Record<string, string> = {
"_default": `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
"astro": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>`,
"c": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>`,
"clojure": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>`,
"console": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>`,
"cpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
"csharp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
"css": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>`,
"dart": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>`,
"database": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>`,
"document": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>`,
"elixir": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>`,
"erlang": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>`,
"go": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00acc1" d="M2 12h4v2H2zm-2 4h6v2H0zm4 4h2v2H4zm16.954-5H14v3h3.239a4.42 4.42 0 0 1-3.531 2 2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 15.292 13a2.73 2.73 0 0 1 1.749.584l2.962-1.185A5.6 5.6 0 0 0 15.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 6.4 6.4 0 0 0 .003-1.5"/><path fill="#00acc1" d="M26.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 5.614 5.614 0 0 0-5.659-6.5m2.681 6.137A4.515 4.515 0 0 1 24.708 20a2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 26.292 13a2.65 2.65 0 0 1 2.053.858 2.86 2.86 0 0 1 .628 2.28Z"/></svg>`,
"gradle": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0097a7" d="M16 10v2h6c-2 0-3-2-6-2"/><path fill="#0097a7" d="M26 4h-2a4 4 0 0 0-4 4h4a1 1 0 0 1 2 0v4H16v-2h-5.317A2.683 2.683 0 0 0 8 12.683v2.634A2.683 2.683 0 0 0 10.683 18H16v2h-5.98A4.02 4.02 0 0 1 6 16v-2c-2 0-4 4-4 8 0 5 1 6 2 6h4v-4h4v4h4v-4h4v4h4v-6a2 2 0 0 0 2-2v-2a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4m-4 12h-2a2 2 0 0 1-2-2h6a2 2 0 0 1-2 2"/></svg>`,
"graphql": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M6 20h20v2H6z"/><circle cx="7" cy="21" r="3" fill="#ec407a"/><circle cx="16" cy="27" r="3" fill="#ec407a"/><circle cx="25" cy="21" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 10h20v2H6z"/><circle cx="7" cy="11" r="3" fill="#ec407a"/><circle cx="16" cy="5" r="3" fill="#ec407a"/><circle cx="25" cy="11" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 12h2v10H6zm18-2h2v12h-2z"/><path fill="#ec407a" d="m5.014 19.41 11.674 6.866L15.674 28 4 21.134z"/><path fill="#ec407a" d="M26.688 21.724 15.014 28.59 14 26.866 25.674 20zM5.124 10.382l11.415-7.29 1.077 1.686L6.2 12.068z"/><path fill="#ec407a" d="m25.798 12.067-11.415-7.29 1.077-1.685 11.415 7.29zM6.2 19.932l11.416 7.29-1.077 1.686-11.415-7.29z"/><path fill="#ec407a" d="m26.875 21.619-11.415 7.29-1.077-1.687 11.415-7.289zM5.877 22.6 16.04 3.686l1.762.946L7.638 23.546z"/><path fill="#ec407a" d="M24.361 23.545 14.197 4.633l1.761-.947 10.165 18.913z"/></svg>`,
"groovy": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#26c6da" d="M19.322 2a6.5 6.5 0 0 1 4.352 1.419 4.55 4.55 0 0 1 1.685 3.662 5.82 5.82 0 0 1-1.886 4.275 6.04 6.04 0 0 1-4.34 1.846 4.15 4.15 0 0 1-2.385-.649 1.91 1.91 0 0 1-.936-1.603 1.6 1.6 0 0 1 .356-1.024 1.1 1.1 0 0 1 .861-.447q.469 0 .468.504a.79.79 0 0 0 .358.693 1.43 1.43 0 0 0 .826.245 3.1 3.1 0 0 0 2.39-1.573 5.66 5.66 0 0 0 1.154-3.39 2.64 2.64 0 0 0-.891-2.064 3.28 3.28 0 0 0-2.293-.812 6.18 6.18 0 0 0-4.086 1.736 12.9 12.9 0 0 0-3.215 4.557 13.4 13.4 0 0 0-1.233 5.36 5.86 5.86 0 0 0 1.091 3.723 3.53 3.53 0 0 0 2.905 1.372q3.058 0 5.848-4.002l2.935-.388q.546-.07.545.246a8 8 0 0 1-.423 1.24q-.421 1.097-1.152 3.668A12.7 12.7 0 0 0 26 17.72v1.66a14.2 14.2 0 0 1-4.055 2.57 10.38 10.38 0 0 1-2.764 5.931 6.7 6.7 0 0 1-4.806 2.11 3.3 3.3 0 0 1-2.012-.55 1.8 1.8 0 0 1-.718-1.514q0-2.685 5.634-5.212.532-1.766 1.152-3.507a8.6 8.6 0 0 1-2.853 2.323 7.4 7.4 0 0 1-3.48 1.01 5.46 5.46 0 0 1-4.366-2.093A8.1 8.1 0 0 1 6 15.122a11.6 11.6 0 0 1 1.966-6.426 14.7 14.7 0 0 1 5.162-4.862A12.44 12.44 0 0 1 19.322 2m-2.407 22.17q-4.055 1.875-4.054 3.695a.87.87 0 0 0 .999.97q1.964 0 3.055-4.665"/></svg>`,
"h": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M18.5 11a5.49 5.49 0 0 0-4.5 2.344V4H8v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
"haskell": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g stroke-width="2.422"><path fill="#ef5350" d="m23.928 240.5 59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z"/><path fill="#ffa726" d="m83.869 240.5 59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z"/><path fill="#ffee58" d="m228.72 188.08-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924-19.98-29.953h99.901v29.953z"/></g></svg>`,
"hcl": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#eceff1" d="M18 1.2V14h-4v-4l-4 2v16.37l4 2.43V18h4v4l4-2V3.63z"/><path fill="#eceff1" d="M14 1.2 2 8.49v15.02l4 2.43v-15.2l8-4.86zm12 4.86v15.2l-8 4.86v4.68l12-7.29V8.49z"/></svg>`,
"hpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 6V2h-2v4h-6V2h-2v4h-4v2h4v4h2V8h6v4h2V8h4V6zm-15.5 5A5.49 5.49 0 0 0 8 13.344V4H2v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
"html": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#e65100" d="m4 4 2 22 10 2 10-2 2-22Zm19.72 7H11.28l.29 3h11.86l-.802 9.335L15.99 25l-6.635-1.646L8.93 19h3.02l.19 2 3.86.77 3.84-.77.29-4H8.84L8 8h16Z"/></svg>`,
"image": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M8.5 6h4l-4-4zM3.875 1H9.5l4 4v8.6c0 .773-.616 1.4-1.375 1.4h-8.25c-.76 0-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4 1.375-1.4M4 13.6h8V8l-2.625 2.8L8 9.4zm1.25-7.7c-.76 0-1.375.627-1.375 1.4s.616 1.4 1.375 1.4c.76 0 1.375-.627 1.375-1.4S6.009 5.9 5.25 5.9"/></svg>`,
"java": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="M4 26h24v2H4zM28 4H7a1 1 0 0 0-1 1v13a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-4h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 8h-4V6h4Z"/></svg>`,
"javascript": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2 2v12h12V2zm6 6h1v4a1.003 1.003 0 0 1-1 1H7a1.003 1.003 0 0 1-1-1v-1h1v1h1zm3 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
"json": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>`,
"kotlin": `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><defs><linearGradient id="a" x1="1.725" x2="22.185" y1="22.67" y2="1.982" gradientTransform="translate(1.306 1.129)scale(.89324)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7c4dff"/><stop offset=".5" stop-color="#d500f9"/><stop offset="1" stop-color="#ef5350"/></linearGradient></defs><path fill="url(#a)" d="M2.975 2.976v18.048h18.05v-.03l-4.478-4.511-4.48-4.515 4.48-4.515 4.443-4.477z"/></svg>`,
"less": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0277bd" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3z"/></svg>`,
"lock": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffd54f" d="M25 12h-3V8a6 6 0 0 0-12 0v4H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V13a1 1 0 0 0-1-1M14 8a2 2 0 0 1 4 0v4h-4Zm2 17a4 4 0 1 1 4-4 4 4 0 0 1-4 4"/></svg>`,
"lua": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="M30 6a3.86 3.86 0 0 1-1.167 2.833 4.024 4.024 0 0 1-5.666 0A3.86 3.86 0 0 1 22 6a3.86 3.86 0 0 1 1.167-2.833 4.024 4.024 0 0 1 5.666 0A3.86 3.86 0 0 1 30 6m-9.208 5.208A10.6 10.6 0 0 0 13 8a10.6 10.6 0 0 0-7.792 3.208A10.6 10.6 0 0 0 2 19a10.6 10.6 0 0 0 3.208 7.792A10.6 10.6 0 0 0 13 30a10.6 10.6 0 0 0 7.792-3.208A10.6 10.6 0 0 0 24 19a10.6 10.6 0 0 0-3.208-7.792m-1.959 7.625a4.024 4.024 0 0 1-5.666 0 4.024 4.024 0 0 1 0-5.666 4.024 4.024 0 0 1 5.666 0 4.024 4.024 0 0 1 0 5.666"/></svg>`,
"markdown": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"/></svg>`,
"nix": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g stroke-width=".395"><path fill="#1976d2" d="M133.347 451.499c0-.295-2.752-5.283-6.116-11.084s-6.116-10.776-6.116-11.055 9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694s-11.836.21-26.302.154c-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.364.96h-13.23c-10.895 0-13.228-.095-13.228-.538zm167.58-125.61c-.134-.216 1.189-2.863 2.939-5.882 6.924-11.944 84.29-145.75 96.49-166.88 7.143-12.371 13.143-22.465 13.334-22.433.362.062 25.86 43.105 25.86 43.655 0 .174-6.761 11.952-15.025 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.563.564 30.213.564h29.996l.957 1.48c.527.814 3.296 5.547 6.155 10.518s5.45 9.29 5.757 9.597c.705.705.703.724-.16 1.572-.396.388-3.36 5.323-6.588 10.965-3.228 5.643-6.056 10.387-6.285 10.543s-19.695.171-43.256.034l-42.84-.249-.803 1.15c-.442.632-7.505 12.736-15.696 26.897l-14.892 25.747h-15.486c-8.518 0-20.015.116-25.551.259-6.55.168-10.15.121-10.308-.135zm-133.75-157.86c-56.373-.055-102.5-.182-102.5-.282s5.617-10.132 12.481-22.294L89.64 123.34h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427s-14.797-25.917-14.797-26.04 2.682-4.853 5.96-10.51 6.003-10.578 6.056-10.934c.086-.586 1.375-.648 13.572-.648 7.412 0 13.463.143 13.446.317-.018.174.22.707.53 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.117.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z"/><path fill="#64b5f6" d="M23.046 238.939c-6.098 10.563-6.69 11.711-6.224 12.078.282.224 3.18 5.044 6.44 10.712s6.016 10.355 6.123 10.417c.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.473.273-1.08 3.094-14.637 26.574l-15.166 26.269 12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956s23.257-39.831 51.492-88.624c11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.096-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.023.037c-.012.003-1.622-.001-1.826 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.567.03-1.147-.03-1.29-.132-.141-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.2 21.131 89.393 155.03 96.276 167 1.503 2.613 2.92 4.803 3.443 5.348.9-1.249 3.532-5.63 7.954-13.219a1343 1343 0 0 1 10.05-17.76l6.606-11.443c.691-1.403.753-1.818.652-2.117-.161-.48-6.903-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.99-26.32a.73.73 0 0 1-.01-.366l-.426-.913 21.636-36.976c3.69-6.307 6.425-11.042 9.471-16.29 9.158-15.948 12.036-21.189 11.895-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.144.035-.42.112-5.455.195-11.19.185s-11.22.024-12.187.073l-1.76.089-14.998 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.271.05-.256-.148-7.144-11.902-15.306-26.119L279.4 48.817c-.116-.186-.444-.744-.458-.752-.476-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.043.48.328 1.05 25.338 43.735 25.772 43.985.206.119 14.178.239 31.05.266 26.65.044 30.749.152 31.234.832.307.43 9.987 17.214 21.513 37.296s21.152 36.627 21.394 36.767 5.926.243 12.633.23c6.705-.013 12.4.099 12.657.246.131.076.381-.141.851-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.44-9.774-.196-.712-.066-.822 1.155-.98 1.956-.252 57.397-.057 58.071.205.237.092.79-.569 2.593-3.497 1.866-3.067 5.03-8.524 11.001-18.866 7.22-12.505 13.043-22.784 12.941-22.843s-.77-.051-1.489.016l-.046.001c-4.451.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.12.01-2.095.076-2.66.172a.3.3 0 0 0-.062.083z"/></g></svg>`,
"ocaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m12.019 15.021.003-.008c-.005-.021-.006-.026-.003.008"/><path fill="#ff9800" d="M4.51 3.273a2.523 2.523 0 0 0-2.524 2.523V11.3c.361-.13.88-.898 1.043-1.085.285-.327.337-.743.478-1.006C3.83 8.612 3.886 8.2 4.62 8.2c.342 0 .478.08.71.39.16.216.438.615.568.882.15.307.396.724.503.808q.122.095.233.137c.119.044.218-.037.297-.1.102-.082.145-.247.24-.467.135-.317.283-.697.367-.83.146-.23.195-.501.352-.633.232-.195.535-.208.618-.225.466-.092.677.225.907.43.15.133.355.403.5.765.114.283.26.544.32.707.059.158.203.41.289.713.077.275.286.486.365.616 0 0 .121.34.858.65.16.067.482.176.674.246.32.116.63.101 1.025.054.281 0 .434-.408.562-.734.075-.193.148-.745.197-.902.048-.153-.064-.27.031-.405.112-.156.178-.164.242-.368.138-.436.936-.458 1.384-.458.374 0 .327.363.96.239.364-.072.714.046 1.1.149.324.086.63.184.812.398.119.139.412.834.113.863.029.035.05.099.104.134-.067.262-.357.075-.518.041-.217-.045-.37.007-.583.101-.363.162-.894.143-1.21.407-.27.223-.269.721-.394 1 0 0-.348.895-1.106 1.443-.194.14-.574.477-1.4.605a5.3 5.3 0 0 1-1.1.043c-.186-.009-.362-.018-.549-.02-.11-.002-.48-.013-.461.022l-.041.103.024.138c.015.083.019.149.022.225.006.157-.013.32-.005.478.017.328.138.627.154.958.017.368.199.758.375 1.059.067.114.169.128.213.269.052.161.003.333.028.505.1.668.292 1.366.592 1.97l.008.014c.371-.062.743-.196 1.226-.267.885-.132 2.115-.064 2.906-.138 2-.188 3.085.82 4.882.407V5.796a2.523 2.523 0 0 0-2.523-2.523zm-.907 11.144q-.022 0-.046.003c-.159.025-.313.08-.412.24-.08.13-.108.355-.164.505-.064.175-.176.338-.274.505-.18.305-.504.581-.644.879-.028.06-.053.13-.076.2v3.402c.163.028.333.062.524.113 1.407.375 1.75.407 3.13.25l.13-.018c.105-.22.187-.968.255-1.2.054-.178.127-.32.155-.5.026-.173-.003-.337-.017-.493-.04-.393.285-.533.44-.87.14-.304.22-.651.336-.963.11-.298.284-.721.579-.872-.036-.041-.617-.06-.772-.076a5 5 0 0 1-.5-.07c-.314-.064-.656-.126-.965-.2a10 10 0 0 1-.947-.328c-.298-.138-.503-.497-.732-.507m5.737.83c-.74.149-.97.876-1.32 1.451-.192.319-.396.59-.548.928-.14.312-.128.657-.368.924a2.55 2.55 0 0 0-.528.922c-.023.067-.088.776-.158.943l1.101-.078c1.026.07.73.464 2.332.378l2.529-.078a7 7 0 0 0-.228-.588c-.07-.147-.16-.434-.218-.56a3.5 3.5 0 0 0-.309-.526c-.184-.215-.227-.23-.28-.503-.095-.473-.344-1.33-.637-1.923-.151-.306-.403-.562-.634-.784-.2-.195-.655-.522-.734-.505z"/></svg>`,
"php": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09q-.42.33-1.29.33h-.87l.53-2.76zm-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59zm7.25-8.33-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18s.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35zM18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76zm-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75z"/></svg>`,
"python": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"/><path fill="#fdd835" d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"/></svg>`,
"r": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1976d2" d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1 1 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z"/></svg>`,
"react": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00bcd4" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#00bcd4" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#00bcd4" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#00bcd4" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
"react_ts": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#0288d1" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#0288d1" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#0288d1" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
"ruby": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f44336" d="M18.041 3.177c2.24.382 2.879 1.919 2.843 3.527V6.67l-1.013 13.266-13.132.897h.008c-1.093-.044-3.518-.151-3.634-3.545l1.217-2.222 2.462 5.74 2.097-6.77-.045.009.018-.018 6.85 2.186L13.945 9.3l6.53-.409-5.144-4.212 2.71-1.51v.009M3.113 17.252v.017zM6.916 6.874c2.63-2.622 6.033-4.168 7.34-2.844 1.297 1.306-.072 4.523-2.702 7.135-2.666 2.613-6.015 4.248-7.322 2.933-1.306-1.324.036-4.612 2.675-7.224z"/></svg>`,
"rust": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff7043" d="m30 12-4-2V6h-4l-2-4-4 2-4-2-2 4H6v4l-4 2 2 4-2 4 4 2v4h4l2 4 4-2 4 2 2-4h4v-4l4-2-2-4ZM6 16a9.9 9.9 0 0 1 .842-4H10v8H6.842A9.9 9.9 0 0 1 6 16m10 10a9.98 9.98 0 0 1-7.978-4H16v-2h-2v-2h4c.819.819.297 2.308 1.179 3.37a1.89 1.89 0 0 0 1.46.63h3.34A9.98 9.98 0 0 1 16 26m-2-12v-2h4a1 1 0 0 1 0 2Zm11.158 6H24a2.006 2.006 0 0 1-2-2 2 2 0 0 0-2-2 3 3 0 0 0 3-3q0-.08-.004-.161A3.115 3.115 0 0 0 19.83 10H8.022a9.986 9.986 0 0 1 17.136 10"/></svg>`,
"sass": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M27.837 5.673a4.33 4.33 0 0 0-2.293-2.701c-2.362-1.261-6.11-1.298-9.548-.092a26.3 26.3 0 0 0-8.76 4.966c-2.752 2.542-3.438 4.925-3.189 6.194.523 2.668 3.274 4.539 5.485 6.042.418.284.822.559 1.175.816-1.429.76-4.261 2.444-5.088 4.248a3.88 3.88 0 0 0-.118 3.332A2.37 2.37 0 0 0 6.869 29.8a5.6 5.6 0 0 0 1.49.2 6.35 6.35 0 0 0 5.19-2.856 6.74 6.74 0 0 0 .864-5.382 7.3 7.3 0 0 1 2.044-.03 3.92 3.92 0 0 1 2.816 1.311 1.82 1.82 0 0 1 .423 1.262 1.55 1.55 0 0 1-.772 1.05c-.234.14-.586.355-.504.803.036.194.198.633.894.512a2.93 2.93 0 0 0 2.145-2.651 4 4 0 0 0-1.197-2.904 5.94 5.94 0 0 0-4.396-1.626 10.6 10.6 0 0 0-2.672.304 20 20 0 0 0-2.203-1.846c-1.712-1.3-3.33-2.529-3.235-4.26.125-2.263 2.468-4.532 6.964-6.744 4.016-1.976 7.254-2.037 8.944-1.438a2 2 0 0 1 1.204.883 2.77 2.77 0 0 1-.36 2.47 9.71 9.71 0 0 1-7.425 4.304 3.86 3.86 0 0 1-3.238-.757c-.278-.302-.593-.645-1.074-.383q-.565.31-.225 1.189a3.9 3.9 0 0 0 2.407 1.92 11.7 11.7 0 0 0 7.128-.671c3.527-1.35 6.681-5.202 5.756-8.787M11.895 24.475a4 4 0 0 1-.192.468 4.5 4.5 0 0 1-.753 1.081 2.83 2.83 0 0 1-2.533 1.107c-.056-.032-.078-.146-.085-.193a3.28 3.28 0 0 1 1.076-2.284 11.3 11.3 0 0 1 2.644-1.933 3.85 3.85 0 0 1-.157 1.754"/></svg>`,
"scala": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="m6.457 9.894 12.523 5.163-.456 1.211L6 11.105Zm7.02-3.091L26 11.966l-.457 1.21L13.02 8.015ZM6.465 18.885l12.524 5.163-.457 1.21L6.01 20.097Zm7.007-3.086 12.524 5.163-.456 1.21-12.524-5.162Z"/><path fill="#f44336" d="M6 24.07V30l19.997-3.106V20.96zM6 5.11v5.99l20-3.11V2zm0 9.96v5.03l20-3.11v-5.03z"/></svg>`,
"settings": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.6.6 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1q.09.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64zm-1.98-1.71c.04.31.05.52.05.73s-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2"/></svg>`,
"svelte": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><path fill="#ff5722" d="M175.94 24.328c-13.037.252-26.009 3.872-37.471 11.174L79.912 72.818a67.13 67.13 0 0 0-30.355 44.906 70.8 70.8 0 0 0 6.959 45.445 67.2 67.2 0 0 0-10.035 25.102 71.54 71.54 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.36 67.36 0 0 0 30.355-44.906 70.77 70.77 0 0 0-6.982-45.422 67.65 67.65 0 0 0 10.059-25.102 71.63 71.63 0 0 0-12.236-54.156v-.18c-15.324-21.925-40.453-33.727-65.342-33.246zm5.137 28.68a46.5 46.5 0 0 1 36.09 19.969 42.98 42.98 0 0 1 7.365 32.557 45 45 0 0 1-1.393 5.455l-1.123 3.37-2.986-2.247a75.9 75.9 0 0 0-22.902-11.45l-2.244-.651.201-2.246a13.16 13.16 0 0 0-2.379-8.711 13.99 13.99 0 0 0-14.953-5.412 12.8 12.8 0 0 0-3.594 1.572l-58.578 37.25a12.24 12.24 0 0 0-5.502 8.15 13.1 13.1 0 0 0 2.246 9.834 14.03 14.03 0 0 0 14.93 5.569 13.5 13.5 0 0 0 3.594-1.573l22.453-14.234a41.8 41.8 0 0 1 11.898-5.232 46.48 46.48 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.363 32.557 40.42 40.42 0 0 1-18.254 27.078l-58.58 37.316a43 43 0 0 1-11.898 5.23A46.545 46.545 0 0 1 82.81 227.14a42.98 42.98 0 0 1-7.341-32.557 38 38 0 0 1 1.39-5.41l1.102-3.37 3.008 2.246a75.9 75.9 0 0 0 22.836 11.361l2.244.65-.201 2.247a13.25 13.25 0 0 0 2.447 8.644 14.03 14.03 0 0 0 15.043 5.569 13.1 13.1 0 0 0 3.592-1.573l58.467-37.316a12.17 12.17 0 0 0 5.502-8.173 12.96 12.96 0 0 0-2.246-9.811 14.03 14.03 0 0 0-15.043-5.568 12.8 12.8 0 0 0-3.592 1.57l-22.453 14.258a42.9 42.9 0 0 1-11.877 5.209 46.52 46.52 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.297-32.557A40.42 40.42 0 0 1 96.798 96.98l58.646-37.316a42.8 42.8 0 0 1 11.811-5.21 46.5 46.5 0 0 1 13.822-1.444z"/></svg>`,
"svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffb300" d="M29.168 14.03a2.7 2.7 0 0 0-1.968-.83 2.51 2.51 0 0 0-1.929.8h-4.443l3.078-3.078a2.835 2.835 0 0 0 2.857-2.842 2.6 2.6 0 0 0-.831-1.969 2.82 2.82 0 0 0-2.014-.788 2.67 2.67 0 0 0-1.968.788 2.36 2.36 0 0 0-.812 1.922L18 11.17V6.726a2.51 2.51 0 0 0 .8-1.929 2.7 2.7 0 0 0-.832-1.968 2.745 2.745 0 0 0-3.936 0 2.7 2.7 0 0 0-.832 1.968 2.51 2.51 0 0 0 .8 1.93v4.443l-3.138-3.138a2.36 2.36 0 0 0-.812-1.922 2.66 2.66 0 0 0-1.968-.788 2.83 2.83 0 0 0-2.014.788 2.6 2.6 0 0 0-.831 1.969 2.74 2.74 0 0 0 .831 2.013 2.8 2.8 0 0 0 2.026.829l3.078 3.078H6.729a2.51 2.51 0 0 0-1.929-.8 2.7 2.7 0 0 0-1.968.831 2.745 2.745 0 0 0 0 3.937 2.7 2.7 0 0 0 1.968.832 2.51 2.51 0 0 0 1.929-.8h4.443l-3.078 3.077a2.835 2.835 0 0 0-2.857 2.842 2.6 2.6 0 0 0 .831 1.969 2.82 2.82 0 0 0 2.014.788 2.67 2.67 0 0 0 1.968-.788 2.36 2.36 0 0 0 .812-1.922L14 20.827v4.444a2.51 2.51 0 0 0-.8 1.929 2.784 2.784 0 0 0 4.768 1.968A2.7 2.7 0 0 0 18.8 27.2a2.51 2.51 0 0 0-.8-1.929v-4.444l3.138 3.138a2.36 2.36 0 0 0 .812 1.922 2.66 2.66 0 0 0 1.968.788 2.83 2.83 0 0 0 2.014-.788 2.6 2.6 0 0 0 .831-1.969 2.74 2.74 0 0 0-.831-2.013 2.8 2.8 0 0 0-2.026-.829L20.828 18h4.443a2.51 2.51 0 0 0 1.93.8 2.784 2.784 0 0 0 1.967-4.769Z"/></svg>`,
"swift": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff6e40" d="M17.087 19.721c-2.36 1.36-5.59 1.5-8.86.1a13.8 13.8 0 0 1-6.23-5.32c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17"/></svg>`,
"terraform": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#5c6bc0" d="m2 10 8 4V6L2 2zm10 5 8 4v-8l-8-4zm0 11 8 4v-8l-8-4zm10-14v8l8-4V8z"/></svg>`,
"toml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cfd8dc" d="M4 6V4h8v2H9v7H7V6z"/><path fill="#ef5350" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z"/></svg>`,
"typescript": `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 16 16"><path fill="#0288d1" d="M2 2v12h12V2zm4 6h3v1H8v4H7V9H6zm5 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
"vue": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#41b883" d="M1.791 3.851 12 21.471 22.209 3.936V3.85H18.24l-6.18 10.616L5.906 3.851z"/><path fill="#35495e" d="m5.907 3.851 6.152 10.617L18.24 3.851h-3.723L12.084 8.03 9.66 3.85z"/></svg>`,
"webassembly": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M22 18h4v4h-4z"/><path fill="#7c4dff" d="M20 2a4 4 0 0 1-8 0H2v28h28V2Zm-2 24h-2v2h-4v-2h-2v2H6v-2H4V16h2v10h4V16h2v10h4V16h2Zm10 2h-2v-4h-4v4h-2V18h2v-2h4v2h2Z"/></svg>`,
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8bc34a" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5 3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41zm11.16 0-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41z"/></svg>`,
"yaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff5252" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2m12 16v-2H9v2zm-4-4v-2H6v2z"/></svg>`,
"zig": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f9a825" d="M2 8h6v4H2zm8 0h12v4H10zm0 12h12v4H10zm14 0h2v4h-2zM8 20l-3 4H2V12h4v8zm14-8h-6l-6 8h6z"/><path fill="#f9a825" d="M16 20h-6l-6 8m12-16h6l6-8m2 4v16h-4V12h-2l3-4z"/></svg>`,
};
const EXTENSION_TO_ICON: Record<string, string> = {
"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();
}

View File

@@ -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<View | null>(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({
<View
style={[
styles.paneTabs,
isFocusModeEnabled &&
trafficLightPadding.side && {
paddingLeft: trafficLightPadding.left,
paddingRight: trafficLightPadding.right,
},
{ paddingLeft: padding.left, paddingRight: padding.right },
]}
>
<WorkspaceDesktopTabsRow

View File

@@ -60,14 +60,3 @@ export function getIsDesktop(): boolean {
}
return result;
}
// Get traffic light padding values (only non-zero on desktop macOS)
export function getTrafficLightPadding(): { left: number; top: number } {
if (!getIsDesktopMac()) {
return { left: 0, top: 0 };
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}

View File

@@ -6,7 +6,7 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStatus = {
serverId: string;
status: DesktopDaemonState;
listen: string;
listen: string | null;
hostname: string | null;
pid: number | null;
home: string;
@@ -82,7 +82,7 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
return {
serverId: toStringOrNull(raw.serverId) ?? "",
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? "",
listen: toStringOrNull(raw.listen),
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "",

View File

@@ -1172,7 +1172,7 @@ export class HostRuntimeStore {
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
try {
const daemon = await startDesktopDaemon();
const listenAddress = daemon.listen.trim();
const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim();
if (!listenAddress) {
return {

View File

@@ -26,9 +26,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
}, [isMobile, openAgentList]);
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<View style={styles.content} {...dragHandlers}>
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>

View File

@@ -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]);
}

View File

@@ -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",

View File

@@ -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());

View File

@@ -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.

View File

@@ -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<SpeechDownloadRow> = {
idField: "modelId",
columns: [
{ header: "MODEL", field: "modelId", width: 36 },
{ header: "STATUS", field: "status", width: 12, color: () => "green" },
],
};
export type SpeechDownloadResult = ListResult<SpeechDownloadRow>;
export interface SpeechDownloadOptions extends CommandOptions {
host?: string;
model?: string[];
}
export async function runSpeechDownloadCommand(
options: SpeechDownloadOptions,
_command: Command,
): Promise<SpeechDownloadResult> {
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(() => {});
}
}

View File

@@ -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 <id>", "Model ID to download (repeatable)", collectMultiple, []),
).action(withOutput(runSpeechDownloadCommand));
return speech;
return new Command("speech").description("Speech commands");
}

View File

@@ -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<SpeechModelListItem> = {
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<SpeechModelListItem>;
export interface SpeechModelsOptions extends CommandOptions {
host?: string;
}
export async function runSpeechModelsCommand(
options: SpeechModelsOptions,
_command: Command,
): Promise<SpeechModelsResult> {
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(() => {});
}
}

View File

@@ -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<void> {
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;
}

View File

@@ -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<SingleResult<TerminalRow>> {
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(() => {});
}
}

View File

@@ -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 <path>", "Workspace directory"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
terminal
.command("create")
.description("Create a terminal")
.option("--cwd <path>", "Workspace directory")
.option("--name <name>", "Terminal name"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
terminal
.command("kill")
.description("Kill a terminal")
.argument("<terminal-id>", "Terminal ID, ID prefix, or name"),
).action(withOutput(runKillCommand));
addDaemonHostOption(
terminal
.command("capture")
.description("Capture terminal output")
.argument("<terminal-id>", "Terminal ID, ID prefix, or name")
.option("--start <n>", "Capture start line")
.option("--end <n>", "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>", "Terminal ID, ID prefix, or name")
.argument("<keys...>", "Keys to send")
.option("-l, --literal", "Send raw keys without interpreting special tokens")
.option("--json", "Output in JSON format"),
).action(runSendKeysCommand);
return terminal;
}

View File

@@ -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<SingleResult<TerminalKillRow>> {
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<ReturnType<typeof connectTerminalClient>>["client"],
terminalId: string,
): Promise<string> {
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;
}

View File

@@ -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<ListResult<TerminalRow>> {
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(() => {});
}
}

View File

@@ -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<TerminalRow> = {
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<TerminalKillRow> = {
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 ?? "-",
};
}

View File

@@ -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<void> {
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;
}
}

View File

@@ -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<ReturnType<typeof connectToDaemon>>,
idOrName: string,
): Promise<string | null> {
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;
}

View File

@@ -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",
);
}

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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",

View File

@@ -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"

View File

@@ -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<string, unk
});
}
function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
const origins = new Set(
(process.env.PASEO_CORS_ORIGINS ?? "")
.split(",")
.map((value) => 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<DesktopDaemonStatus> {
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<DesktopDaemonStatus> {
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<DesktopDaemonStatus> {
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<DesktopPairingOffer> {
}
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}` };

View File

@@ -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"],
};

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.37",
"version": "0.1.38",
"type": "module",
"publishConfig": {
"access": "public"

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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)],

View File

@@ -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;

View File

@@ -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<void> {
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<void> {
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<void> {
})
: undefined,
restartOnCrash: config.devMode,
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
},
onSupervisorExit: releaseLock,
});
}

View File

@@ -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> | void;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | 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();

View File

@@ -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<SpeechModelsListPayload> {
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<SpeechModelsDownloadPayload> {
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<ListCommandsPayload>;
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
async listCommands(
@@ -2737,11 +2709,11 @@ export class DaemonClient {
});
}
async listTerminals(cwd: string, requestId?: string): Promise<ListTerminalsPayload> {
async listTerminals(cwd?: string, requestId?: string): Promise<ListTerminalsPayload> {
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<CaptureTerminalPayload> {
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<ChatCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,

View File

@@ -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<InMemoryTransport> => {
@@ -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 {

View File

@@ -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);

View File

@@ -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",
},
]);
});
});
});

View File

@@ -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,
);

View File

@@ -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<void>
)(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<void>
)(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<void>

View File

@@ -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<void> {
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<void> {
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 },

File diff suppressed because it is too large Load Diff

View File

@@ -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<InitializedLocalSpeech>>(),
@@ -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();
});
});

View File

@@ -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<void>;
};
export async function initializeSpeechRuntime(params: {
export function createSpeechService(params: {
logger: Logger;
openaiConfig?: PaseoOpenAIConfig;
speechConfig?: PaseoSpeechConfig;
}): Promise<InitializedSpeechRuntime> {
}): 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<void>((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,
};
}

View File

@@ -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<SpeechToTextProvider | null>;
private readonly tts: Resolvable<TextToSpeechProvider | null>;
private readonly turnDetection: Resolvable<TurnDetectionProvider | null>;
private readonly speech: SpeechService | null;
private readonly terminalManager: TerminalManager | null;
private readonly dictation: {
finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
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<string, number>();
private readonly requestLatencies = new Map<string, number[]>();
private runtimeMetricsInterval: ReturnType<typeof setInterval> | 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<TurnDetectionProvider | null>;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
},
speech?: SpeechService | null,
terminalManager?: TerminalManager | null,
voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
@@ -313,12 +298,6 @@ export class VoiceAssistantWebSocketServer {
},
dictation?: {
finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
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<void> {
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,
});

View File

@@ -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<void>;
createBranchFromBase: (params: {
cwd: string;
baseBranch: string;
newBranchName: string;
}) => Promise<void>;
};
type ArchivePaseoWorktreeDependencies = {
paseoHome?: string;
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
};
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<ProjectPlacementPayload>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
paseoHome?: string;
emitWorkspaceUpdateForCwd: (
cwd: string,
options?: { dedupeGitState?: boolean },
) => Promise<void>;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
};
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
) => Promise<WorkspaceDescriptorPayload>;
emit: EmitSessionMessage;
registerPendingWorktreeWorkspace: (options: {
repoRoot: string;
worktreePath: string;
branchName: string;
}) => Promise<PersistedWorkspaceRecord>;
sessionLogger: Logger;
createPaseoWorktreeInBackground: (options: {
requestCwd: string;
repoRoot: string;
baseBranch: string;
slug: string;
worktreePath: string;
}) => Promise<void>;
};
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<string, string>,
): 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<string> {
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<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
): Promise<void> {
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<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([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<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & {
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
},
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
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<PersistedWorkspaceRecord> {
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<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
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<void> {
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<void> {
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})`);
}
}

View File

@@ -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<typeof ListAvailableProvidersResponseSchema>;
export type SpeechModelsListResponse = z.infer<typeof SpeechModelsListResponseSchema>;
export type SpeechModelsDownloadResponse = z.infer<typeof SpeechModelsDownloadResponseSchema>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
@@ -2453,8 +2429,6 @@ export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
export type SpeechModelsListRequestMessage = z.infer<typeof SpeechModelsListRequestSchema>;
export type SpeechModelsDownloadRequestMessage = z.infer<typeof SpeechModelsDownloadRequestSchema>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
@@ -2525,6 +2499,8 @@ export type TerminalCursor = z.infer<typeof TerminalCursorSchema>;
export type TerminalState = z.infer<typeof TerminalStateSchema>;
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema>;
export type CaptureTerminalResponse = z.infer<typeof CaptureTerminalResponseSchema>;
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// ============================================================================

View File

@@ -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<string, string>;
}
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<TerminalSession> {
const {
cwd,

View File

@@ -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);
});
});

View File

@@ -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<WorktreeDestroyCommandResult[]> {
}): Promise<WorktreeTeardownCommandResult[]> {
// 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,
});

View File

@@ -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"]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.37",
"version": "0.1.38",
"private": true,
"type": "module",
"scripts": {

View File

@@ -300,8 +300,8 @@ function MultiProviderSection() {
return (
<FeatureSection
title="Multi-provider"
description="Escape the vendor lock, mix and match frontier models through a single interface."
title="Use the best agent for the job"
description="Run multiple providers from a single interface. Paseo runs the native agent harness as you'd normally run it, with your skills, config and MCP servers intact."
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{providers.map((p) => (
@@ -320,10 +320,10 @@ function MultiProviderSection() {
function SelfHostedDiagram() {
const clients = [
{ name: "Desktop", icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40"><rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" /></svg> },
{ name: "Web", icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg> },
{ name: "Mobile", icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40"><rect x="5" y="2" width="14" height="20" rx="2" /><path d="M12 18h.01" /></svg> },
{ name: "CLI", icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40"><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></svg> },
{ name: "Desktop", icon: <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" /></svg> },
{ name: "Web", icon: <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg> },
{ name: "Mobile", icon: <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" /><path d="M12 18h.01" /></svg> },
{ name: "CLI", icon: <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></svg> },
];
const hosts = ["MacBook Pro", "Hetzner VM", "Dev server"];
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -376,9 +376,9 @@ function SelfHostedDiagram() {
<div className="md:hidden flex flex-col items-center gap-4 py-4">
<div className="space-y-2 w-full">
{clients.map((c) => (
<div key={c.name} className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm">
{c.icon}
{c.name}
<div key={c.name} className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4">
<span className="text-white/80">{c.icon}</span>
<span className="font-medium">{c.name}</span>
</div>
))}
</div>
@@ -391,14 +391,14 @@ function SelfHostedDiagram() {
<div className="w-px h-6 border-l border-dashed border-white/25" />
<div className="space-y-2 w-full">
{hosts.map((h) => (
<div key={h} className="flex items-center gap-3 rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40">
<div key={h} className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4">
<span className="text-white/80"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<circle cx="6" cy="6" r="1" />
<circle cx="6" cy="18" r="1" />
</svg>
{h}
</svg></span>
<span className="font-medium">{h}</span>
</div>
))}
</div>
@@ -419,10 +419,10 @@ function SelfHostedDiagram() {
<div
key={c.name}
ref={(el) => { 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}
<span className="text-white/80">{c.icon}</span>
<span className="font-medium">{c.name}</span>
</div>
))}
</div>
@@ -431,10 +431,10 @@ function SelfHostedDiagram() {
<div className="flex-1" />
{/* Center label */}
<div ref={centerRef} className="flex-shrink-0 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-5 text-center space-y-1 relative z-10 backdrop-blur-sm">
<p className="text-xs font-medium text-white/50">E2E Encrypted Relay</p>
<p className="text-[10px] text-white/25">or</p>
<p className="text-xs font-medium text-white/50">Direct Connection</p>
<div ref={centerRef} className="flex-shrink-0 rounded-xl border border-white/10 bg-white/[0.03] px-8 py-6 text-center space-y-1.5 relative z-10 backdrop-blur-sm">
<p className="text-sm font-medium text-white/50">E2E Encrypted Relay</p>
<p className="text-xs text-white/25">or</p>
<p className="text-sm font-medium text-white/50">Direct Connection</p>
</div>
{/* Spacer */}
@@ -446,15 +446,15 @@ function SelfHostedDiagram() {
<div
key={h}
ref={(el) => { 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"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40">
<span className="text-white/80"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<circle cx="6" cy="6" r="1" />
<circle cx="6" cy="18" r="1" />
</svg>
{h}
</svg></span>
<span className="font-medium">{h}</span>
</div>
))}
</div>
@@ -466,8 +466,8 @@ function SelfHostedDiagram() {
function SelfHostedSection() {
return (
<FeatureSection
title="Self-hosted"
description="Run the daemon wherever you want and connect however you want. Orchestrate agents in multiple hosts from a single interface."
title="Your agents, every surface"
description="Run agents on your laptop, a VM, or a dev server. Control them from any device with a direct connection or an E2E encrypted relay."
>
<SelfHostedDiagram />
</FeatureSection>

View File

@@ -96,7 +96,7 @@ function Worktrees() {
{/* paseo.json */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Setup with paseo.json</h2>
<h2 className="text-xl font-medium">Lifecycle hooks with paseo.json</h2>
<p className="text-white/60 leading-relaxed">
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.
</p>
<p className="text-white/60 leading-relaxed">
You can also add a <code className="font-mono">teardown</code> array for cleanup commands
that run before Paseo removes the worktree directory during archive:
</p>
<Code>
<pre className="text-white/80">{`{
"worktree": {
"teardown": [
"pkill -f \\"vite --port $PASEO_WORKTREE_PORT\\" || true",
"rm -rf \\"$PASEO_WORKTREE_PATH/.cache\\""
]
}
}`}</pre>
</Code>
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 text-white/80">
<strong>Important:</strong> Setup commands come from{" "}
<code className="font-mono">paseo.json</code> in the selected base branch. If you pick{" "}
@@ -130,7 +144,7 @@ function Worktrees() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Environment variables</h2>
<p className="text-white/60 leading-relaxed">
Setup commands have access to these environment variables:
Setup and teardown commands have access to these environment variables:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
@@ -148,8 +162,8 @@ function Worktrees() {
<code className="font-mono">$PASEO_BRANCH_NAME</code> the branch name created
</li>
<li>
<code className="font-mono">$PASEO_WORKTREE_PORT</code> an available local port for
setup scripts
<code className="font-mono">$PASEO_WORKTREE_PORT</code> the worktree port, when
runtime metadata exists
</li>
</ul>
<p className="text-white/60 leading-relaxed">
@@ -157,6 +171,39 @@ function Worktrees() {
shouldn't be in git (like <code className="font-mono">.env</code>) from your source
checkout to the worktree.
</p>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">$PASEO_WORKTREE_PORT</code> 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.
</p>
</section>
{/* Teardown */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Teardown</h2>
<p className="text-white/60 leading-relaxed">
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.
</p>
<p className="text-white/60 leading-relaxed">
Common uses include stopping dev servers on{" "}
<code className="font-mono">$PASEO_WORKTREE_PORT</code>, deleting generated files, or
deregistering services tied to that worktree.
</p>
<Code>
<pre className="text-white/80">{`{
"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\\""
]
}
}`}</pre>
</Code>
</section>
{/* Common patterns */}

View File

@@ -154,6 +154,59 @@ paseo chat wait <room> --timeout <duration>
paseo chat delete <name-or-id>
```
## 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 <terminal-id>
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 <terminal-id> # Visible pane only, ANSI stripped
paseo terminal capture <terminal-id> --scrollback # Full scrollback + visible
paseo terminal capture <terminal-id> -S # Short form of --scrollback
paseo terminal capture <terminal-id> --start 0 --end 10 # Line range (tmux-style)
paseo terminal capture <terminal-id> --start -5 # Last 5 lines
paseo terminal capture <terminal-id> --ansi # Preserve ANSI escape codes
paseo terminal capture <terminal-id> --json # JSON output with metadata
# Send keystrokes (like tmux send-keys)
paseo terminal send-keys <terminal-id> "ls -la" Enter
paseo terminal send-keys <terminal-id> "echo hello" Enter
paseo terminal send-keys <terminal-id> C-c # Ctrl+C
paseo terminal send-keys <terminal-id> C-d # Ctrl+D
paseo terminal send-keys <terminal-id> --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: