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
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']
+2
View File
@@ -9,6 +9,8 @@ on:
- 'package-lock.json' - 'package-lock.json'
- 'patches/**' - 'patches/**'
- '.github/workflows/deploy-website.yml' - '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+16
View File
@@ -1,5 +1,21 @@
# Changelog # 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 ## 0.1.36 - 2026-03-27
### Fixed ### Fixed
+109
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 |
+2 -2
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # 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 # 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). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -114,7 +114,7 @@ buildNpmPackage rec {
fi fi
done 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 if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/ cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
+122 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.37", "version": "0.1.38",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.37", "version": "0.1.38",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -15364,6 +15364,13 @@
"node": ">=10" "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": { "node_modules/chrome-launcher": {
"version": "0.15.2", "version": "0.15.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
@@ -16386,6 +16393,33 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -19579,6 +19613,13 @@
"node": ">=6" "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": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -26475,6 +26516,25 @@
"node": ">=10" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -31390,6 +31450,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -33421,6 +33491,17 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -35873,6 +35954,16 @@
"uuid": "dist/bin/uuid" "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": { "node_modules/xml-name-validator": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
@@ -35883,6 +35974,17 @@
"node": ">=12" "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": { "node_modules/xml2js": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
@@ -36183,16 +36285,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.37", "version": "0.1.38",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.37", "@getpaseo/expo-two-way-audio": "0.1.38",
"@getpaseo/highlight": "0.1.37", "@getpaseo/highlight": "0.1.38",
"@getpaseo/server": "0.1.37", "@getpaseo/server": "0.1.38",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -36270,6 +36372,7 @@
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1", "playwright": "^1.56.1",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"vitest": "^3.2.4", "vitest": "^3.2.4",
@@ -36308,11 +36411,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.37", "version": "0.1.38",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.37", "@getpaseo/relay": "0.1.38",
"@getpaseo/server": "0.1.37", "@getpaseo/server": "0.1.38",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -36353,11 +36456,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.37", "version": "0.1.38",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.37", "@getpaseo/cli": "0.1.38",
"@getpaseo/server": "0.1.37", "@getpaseo/server": "0.1.38",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -36391,7 +36494,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.37", "version": "0.1.38",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -36592,7 +36695,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.37", "version": "0.1.38",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -36618,7 +36721,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.37", "version": "0.1.38",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -36634,13 +36737,13 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.37", "version": "0.1.38",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.37", "@getpaseo/highlight": "0.1.38",
"@getpaseo/relay": "0.1.37", "@getpaseo/relay": "0.1.38",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -37042,7 +37145,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.37", "version": "0.1.38",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.37", "version": "0.1.38",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
+5 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.37", "version": "0.1.38",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.37", "@getpaseo/expo-two-way-audio": "0.1.38",
"@getpaseo/highlight": "0.1.37", "@getpaseo/highlight": "0.1.38",
"@getpaseo/server": "0.1.37", "@getpaseo/server": "0.1.38",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -111,6 +111,7 @@
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1", "playwright": "^1.56.1",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"vitest": "^3.2.4", "vitest": "^3.2.4",
@@ -17,6 +17,7 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane"; import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {} function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -336,12 +337,13 @@ function SidebarContent({
onOpenFile, onOpenFile,
}: SidebarContentProps) { }: SidebarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const padding = useWindowControlsPadding("explorerSidebar");
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab; const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return ( return (
<View style={styles.sidebarContent} pointerEvents="auto"> <View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */} {/* 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}> <View style={styles.tabsContainer}>
{isGit && ( {isGit && (
<Pressable <Pressable
@@ -26,18 +26,17 @@ import Animated, {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import { import {
ChevronDown,
ChevronRight,
Copy, Copy,
Download, Download,
File,
FileText,
Folder,
FolderOpen,
Image as ImageIcon,
MoreVertical, MoreVertical,
RotateCw, RotateCw,
X, X,
} from "lucide-react-native"; } from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -65,7 +64,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "size", label: "Size" }, { value: "size", label: "Size" },
]; ];
const INDENT_PER_LEVEL = 12; const INDENT_PER_LEVEL = 16;
function formatFileSize({ size }: { size: number }): string { function formatFileSize({ size }: { size: number }): string {
if (size < 1024) { if (size < 1024) {
@@ -384,7 +383,6 @@ export function FileExplorerPane({
({ item }: ListRenderItemInfo<TreeRow>) => { ({ item }: ListRenderItemInfo<TreeRow>) => {
const entry = item.entry; const entry = item.entry;
const depth = item.depth; const depth = item.depth;
const displayKind = getEntryDisplayKind(entry);
const isDirectory = entry.kind === "directory"; const isDirectory = entry.kind === "directory";
const isExpanded = isDirectory && expandedPaths.has(entry.path); const isExpanded = isDirectory && expandedPaths.has(entry.path);
const isSelected = selectedEntryPath === entry.path; const isSelected = selectedEntryPath === entry.path;
@@ -399,16 +397,30 @@ export function FileExplorerPane({
(hovered || pressed || isSelected) && styles.entryRowActive, (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.entryInfo}>
<View style={styles.entryIcon}> <View style={styles.entryIcon}>
{loading ? ( {isDirectory ? (
<ActivityIndicator size="small" /> loading ? (
<ActivityIndicator size="small" />
) : (
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
)
) : ( ) : (
renderEntryIcon(isDirectory ? "directory" : displayKind, { <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
foreground: theme.colors.foregroundMuted,
primary: theme.colors.primary,
directoryOpen: isExpanded,
})
)} )}
</View> </View>
<Text style={styles.entryName} numberOfLines={1}> <Text style={styles.entryName} numberOfLines={1}>
@@ -552,27 +564,31 @@ export function FileExplorerPane({
) : ( ) : (
<View style={[styles.treePane, styles.treePaneFill]}> <View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header"> <View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} /> <Pressable
<View style={styles.paneHeaderRight}> onPress={handleSortCycle}
<Pressable style={({ hovered, pressed }) => [
onPress={handleRefresh} styles.sortTrigger,
disabled={isRefreshFetching} (hovered || pressed) && styles.sortTriggerHovered,
hitSlop={8} ]}
style={({ hovered, pressed }) => [ >
styles.iconButton, <Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
(hovered || pressed) && styles.iconButtonHovered, <ChevronDown size={12} color={theme.colors.foregroundMuted} />
]} </Pressable>
accessibilityRole="button" <Pressable
accessibilityLabel="Refresh files" onPress={handleRefresh}
> disabled={isRefreshFetching}
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}> hitSlop={8}
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> style={({ hovered, pressed }) => [
</Animated.View> styles.iconButton,
</Pressable> (hovered || pressed) && styles.iconButtonHovered,
<Pressable style={styles.sortButton} onPress={handleSortCycle}> ]}
<Text style={styles.sortButtonText}>{currentSortLabel}</Text> accessibilityRole="button"
</Pressable> accessibilityLabel="Refresh files"
</View> >
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
</View> </View>
<FlatList <FlatList
ref={treeListRef} 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[] { function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries]; const sorted = [...entries];
sorted.sort((a, b) => { sorted.sort((a, b) => {
@@ -842,50 +765,38 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0, minWidth: 0,
}, },
paneHeader: { paneHeader: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
height: WORKSPACE_SECONDARY_HEADER_HEIGHT, paddingRight: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
}, },
paneHeaderLeft: { sortTrigger: {
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,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
paddingHorizontal: theme.spacing[2], gap: theme.spacing[1],
borderRadius: theme.borderRadius.md, marginLeft: theme.spacing[3] - theme.spacing[1],
borderWidth: theme.borderWidth[1], paddingHorizontal: theme.spacing[1],
borderColor: theme.colors.border, height: 24,
borderRadius: theme.borderRadius.base,
}, },
sortButtonText: { sortTriggerHovered: {
color: theme.colors.foregroundMuted, backgroundColor: theme.colors.surface2,
},
sortTriggerText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
}, },
treeList: { treeList: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
}, },
entriesContent: { entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4], paddingBottom: theme.spacing[4],
}, },
centerState: { centerState: {
@@ -936,8 +847,16 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between", justifyContent: "space-between",
paddingVertical: 2, paddingVertical: 2,
paddingRight: theme.spacing[2], paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
}, },
entryRowActive: { entryRowActive: {
backgroundColor: theme.colors.surface1,
},
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
entryInfo: { entryInfo: {
@@ -947,6 +866,16 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
minWidth: 0, minWidth: 0,
}, },
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
entryIcon: { entryIcon: {
flexShrink: 0, flexShrink: 0,
}, },
@@ -7,8 +7,7 @@ import {
HEADER_INNER_HEIGHT_MOBILE, HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE, HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout"; } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window"; import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
interface ScreenHeaderProps { interface ScreenHeaderProps {
left?: ReactNode; left?: ReactNode;
@@ -26,15 +25,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const padding = useWindowControlsPadding("header");
const trafficLightPadding = useTrafficLightPadding();
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets // 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 topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2]; const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const dragHandlers = useDesktopDragHandlers(); const dragHandlers = useDesktopDragHandlers();
@@ -45,8 +39,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
style={[ style={[
styles.row, styles.row,
{ {
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left, paddingLeft: baseHorizontalPadding + padding.left,
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right, paddingRight: baseHorizontalPadding + padding.right,
}, },
borderless && styles.borderless, borderless && styles.borderless,
]} ]}
@@ -90,6 +84,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
borderless: { borderless: {
borderBottomWidth: 0, borderBottomColor: "transparent",
}, },
})); }));
+3 -6
View File
@@ -35,7 +35,7 @@ import {
type SidebarProjectEntry, type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list"; } from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; 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 { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons"; import { formatConnectionStatus } from "@/utils/daemons";
@@ -632,7 +632,7 @@ function DesktopSidebar({
}: DesktopSidebarProps) { }: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent"); const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers(); const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding(); const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions(); const { width: viewportWidth } = useWindowDimensions();
@@ -682,9 +682,7 @@ function DesktopSidebar({
return ( return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}> <Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.side === 'left' ? ( {padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}> <View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} /> <SessionsButton onPress={handleViewMore} />
@@ -811,7 +809,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarContent: { sidebarContent: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflow: "hidden",
}, },
desktopSidebar: { desktopSidebar: {
position: "relative", position: "relative",
@@ -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();
}
@@ -32,7 +32,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useTrafficLightPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { import {
computeTabDropPreview, computeTabDropPreview,
type TabDropPreview, type TabDropPreview,
@@ -792,8 +792,7 @@ function SplitPaneView({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null); const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane); const stableOnFocusPane = useStableEvent(onFocusPane);
const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled); const padding = useWindowControlsPadding("tabRow");
const trafficLightPadding = useTrafficLightPadding();
const paneState = useMemo( const paneState = useMemo(
() => () =>
deriveWorkspacePaneState({ deriveWorkspacePaneState({
@@ -868,11 +867,7 @@ function SplitPaneView({
<View <View
style={[ style={[
styles.paneTabs, styles.paneTabs,
isFocusModeEnabled && { paddingLeft: padding.left, paddingRight: padding.right },
trafficLightPadding.side && {
paddingLeft: trafficLightPadding.left,
paddingRight: trafficLightPadding.right,
},
]} ]}
> >
<WorkspaceDesktopTabsRow <WorkspaceDesktopTabsRow
-11
View File
@@ -60,14 +60,3 @@ export function getIsDesktop(): boolean {
} }
return result; 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,
};
}
@@ -6,7 +6,7 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStatus = { export type DesktopDaemonStatus = {
serverId: string; serverId: string;
status: DesktopDaemonState; status: DesktopDaemonState;
listen: string; listen: string | null;
hostname: string | null; hostname: string | null;
pid: number | null; pid: number | null;
home: string; home: string;
@@ -82,7 +82,7 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
return { return {
serverId: toStringOrNull(raw.serverId) ?? "", serverId: toStringOrNull(raw.serverId) ?? "",
status: parseDesktopDaemonState(raw.status), status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? "", listen: toStringOrNull(raw.listen),
hostname: toStringOrNull(raw.hostname), hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid), pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "", home: toStringOrNull(raw.home) ?? "",
+1 -1
View File
@@ -1172,7 +1172,7 @@ export class HostRuntimeStore {
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> { async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
try { try {
const daemon = await startDesktopDaemon(); const daemon = await startDesktopDaemon();
const listenAddress = daemon.listen.trim(); const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim(); const serverId = daemon.serverId.trim();
if (!listenAddress) { if (!listenAddress) {
return { return {
@@ -26,9 +26,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
}, [isMobile, openAgentList]); }, [isMobile, openAgentList]);
return ( return (
<View style={styles.container} {...dragHandlers}> <View style={styles.container}>
<MenuHeader borderless /> <MenuHeader borderless />
<View style={styles.content}> <View style={styles.content} {...dragHandlers}>
<View style={styles.logo}> <View style={styles.logo}>
<PaseoLogo size={56} /> <PaseoLogo size={56} />
</View> </View>
+53 -15
View File
@@ -10,6 +10,7 @@ import {
} from "@/constants/layout"; } from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window"; import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host"; import { isDesktop } from "@/desktop/host";
import { usePanelStore } from "@/stores/panel-store";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates"; import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
export async function toggleMaximize() { export async function toggleMaximize() {
@@ -123,7 +124,15 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
}, [isActive]); }, [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); const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -178,23 +187,52 @@ export function useTrafficLightPadding(): { left: number; right: number; top: nu
}; };
}, []); }, []);
if (!getIsDesktop() || isFullscreen) { return useMemo((): RawWindowControlsPadding => {
return { left: 0, right: 0, top: 0, side: null }; 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 { return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH, left: 0,
right: 0, right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
side: 'left',
}; };
}, [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 { return useMemo(() => ({ left, right, top }), [left, right, top]);
left: 0,
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
side: 'right',
};
} }
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.37", "version": "0.1.38",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module", "type": "module",
"files": [ "files": [
@@ -24,8 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.37", "@getpaseo/relay": "0.1.38",
"@getpaseo/server": "0.1.37", "@getpaseo/server": "0.1.38",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+4
View File
@@ -8,6 +8,7 @@ import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js"; import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js"; import { createSpeechCommand } from "./commands/speech/index.js";
import { createTerminalCommand } from "./commands/terminal/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js"; import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
@@ -143,6 +144,9 @@ export function createCli(): Command {
// Chat commands // Chat commands
program.addCommand(createChatCommand()); program.addCommand(createChatCommand());
// Terminal commands
program.addCommand(createTerminalCommand());
// Loop commands // Loop commands
program.addCommand(createLoopCommand()); program.addCommand(createLoopCommand());
@@ -124,11 +124,16 @@ function resolveDaemonRunnerEntry(): string {
try { try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string }; const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
if (packageJson.name === "@getpaseo/server") { 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)) { if (existsSync(distRunner)) {
return distRunner; return distRunner;
} }
return path.join(currentDir, "scripts", "daemon-runner.ts"); return path.join(currentDir, "scripts", "supervisor-entrypoint.ts");
} }
} catch { } catch {
// Continue searching up if package.json exists but is invalid. // Continue searching up if package.json exists but is invalid.
@@ -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(() => {});
}
}
+1 -18
View File
@@ -1,22 +1,5 @@
import { Command } from "commander"; 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 { export function createSpeechCommand(): Command {
const speech = new Command("speech").description("Manage local speech models"); return new Command("speech").description("Speech commands");
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;
} }
@@ -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(() => {});
}
}
@@ -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;
}
@@ -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(() => {});
}
}
@@ -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;
}
@@ -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;
}
+34
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(() => {});
}
}
@@ -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 ?? "-",
};
}
@@ -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;
}
}
@@ -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;
}
@@ -2,7 +2,7 @@
/** /**
* Regression: `paseo daemon stop` must stop supervised dev daemons * 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"; import assert from "node:assert";
@@ -120,20 +120,24 @@ let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = ""; let recentSupervisorLogs = "";
try { 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"], { supervisorProcess = spawn(
cwd: cliRoot, "npx",
env: { ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
...process.env, {
...testEnv, cwd: cliRoot,
PASEO_HOME: paseoHome, env: {
PASEO_LISTEN: `127.0.0.1:${port}`, ...process.env,
PASEO_RELAY_ENABLED: "false", ...testEnv,
CI: "true", 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) => { supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
@@ -161,8 +165,9 @@ try {
const command = readProcessCommand(daemonPid); const command = readProcessCommand(daemonPid);
assert(command !== null, "pid lock pid should resolve to a running process command"); assert(command !== null, "pid lock pid should resolve to a running process command");
assert( assert(
command.includes("daemon-runner.ts") || command.includes("daemon-runner.js"), command.includes("supervisor-entrypoint.ts") ||
`pid lock pid should be daemon-runner process, got: ${command}`, 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`); console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`);
@@ -186,7 +191,7 @@ try {
await waitFor( await waitFor(
() => !isProcessRunning(supervisorProcess!.pid ?? -1), () => !isProcessRunning(supervisorProcess!.pid ?? -1),
15000, 15000,
"daemon-runner supervisor remained running after stop", "supervisor-entrypoint process remained running after stop",
); );
} }
@@ -1,7 +1,7 @@
#!/usr/bin/env npx tsx #!/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). * graceful daemon lifecycle shutdown to complete (no early forced exit path).
*/ */
@@ -131,21 +131,25 @@ let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = ""; let recentSupervisorLogs = "";
try { 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"], { supervisorProcess = spawn(
cwd: cliRoot, "npx",
env: { ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
...process.env, {
...testEnv, cwd: cliRoot,
PASEO_HOME: paseoHome, env: {
PASEO_LISTEN: `127.0.0.1:${port}`, ...process.env,
PASEO_RELAY_ENABLED: "false", ...testEnv,
CI: "true", 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) => { supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
@@ -62,7 +62,7 @@ try {
"-e", "-e",
// Keep the process alive long enough for stop command assertions. // Keep the process alive long enough for stop command assertions.
"setInterval(() => {}, 1000)", "setInterval(() => {}, 1000)",
"daemon-runner.ts", "supervisor-entrypoint.ts",
], ],
{ {
env: { env: {
@@ -122,20 +122,24 @@ let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = ""; let recentSupervisorLogs = "";
try { 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"], { supervisorProcess = spawn(
cwd: cliRoot, "npx",
env: { ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
...process.env, {
...testEnv, cwd: cliRoot,
PASEO_HOME: paseoHome, env: {
PASEO_LISTEN: host, ...process.env,
PASEO_RELAY_ENABLED: "false", ...testEnv,
CI: "true", 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) => { supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
@@ -134,11 +134,6 @@ try {
env: { env: {
...process.env, ...process.env,
...testEnv, ...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_HOME: paseoHome,
PASEO_LISTEN: host, PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false", PASEO_RELAY_ENABLED: "false",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.37", "version": "0.1.38",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js", "main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.37", "@getpaseo/cli": "0.1.38",
"@getpaseo/server": "0.1.37", "@getpaseo/server": "0.1.38",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
+13 -40
View File
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { app, ipcMain } from "electron"; import { app, ipcMain } from "electron";
import log from "electron-log/main"; import log from "electron-log/main";
import { loadConfig, resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server"; import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server";
import { import {
copyAttachmentFileToManagedStorage, copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile, deleteManagedAttachmentFile,
@@ -27,14 +27,13 @@ const STARTUP_POLL_MAX_ATTEMPTS = 150;
const STOP_TIMEOUT_MS = 15_000; const STOP_TIMEOUT_MS = 15_000;
const KILL_TIMEOUT_MS = 3_000; const KILL_TIMEOUT_MS = 3_000;
const DETACHED_STARTUP_GRACE_MS = 1200; const DETACHED_STARTUP_GRACE_MS = 1200;
const DEFAULT_ELECTRON_DEV_SERVER_URL = "http://localhost:8081";
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
type DesktopDaemonStatus = { type DesktopDaemonStatus = {
serverId: string; serverId: string;
status: DesktopDaemonState; status: DesktopDaemonState;
listen: string; listen: string | null;
hostname: string | null; hostname: string | null;
pid: number | null; pid: number | null;
home: string; 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 { function toTrimmedString(value: unknown): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -246,12 +221,11 @@ function resolveDesktopAppVersion(): string {
function resolveStatus(): DesktopDaemonStatus { function resolveStatus(): DesktopDaemonStatus {
const home = getPaseoHome(); const home = getPaseoHome();
const config = loadConfig(home, { env: process.env });
const pidPath = pidFilePath(); const pidPath = pidFilePath();
let pid: number | null = null; let pid: number | null = null;
let hostname: string | null = null; let hostname: string | null = null;
let listen: string = config.listen; let listen: string | null = null;
try { try {
if (existsSync(pidPath)) { if (existsSync(pidPath)) {
@@ -266,7 +240,7 @@ function resolveStatus(): DesktopDaemonStatus {
: typeof parsed.sockPath === "string" : typeof parsed.sockPath === "string"
? (parsed.sockPath as string) ? (parsed.sockPath as string)
: null; : null;
if (pidListen) listen = pidListen; listen = pidListen;
} }
} }
} catch { } catch {
@@ -297,18 +271,12 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
const current = resolveStatus(); const current = resolveStatus();
if (current.status === "running") return current; if (current.status === "running") return current;
const home = getPaseoHome();
const daemonRunner = resolveDaemonRunnerEntrypoint(); const daemonRunner = resolveDaemonRunnerEntrypoint();
const corsOrigins = buildDesktopDaemonCorsOriginsEnv();
const invocation = createNodeEntrypointInvocation({ const invocation = createNodeEntrypointInvocation({
entrypoint: daemonRunner, entrypoint: daemonRunner,
argvMode: "node-script", argvMode: "node-script",
args: [], args: [],
baseEnv: { baseEnv: process.env,
...process.env,
PASEO_HOME: home,
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
},
}); });
logDesktopDaemonLifecycle("starting detached daemon", { logDesktopDaemonLifecycle("starting detached daemon", {
@@ -317,8 +285,6 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
daemonRunnerExecArgv: daemonRunner.execArgv, daemonRunnerExecArgv: daemonRunner.execArgv,
command: invocation.command, command: invocation.command,
args: invocation.args, args: invocation.args,
listen: process.env.PASEO_LISTEN ?? null,
corsOrigins: corsOrigins ?? null,
}); });
const child: ChildProcess = spawn( const child: ChildProcess = spawn(
@@ -388,7 +354,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
serverId: status.serverId || null, 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); await sleep(STARTUP_POLL_INTERVAL_MS);
} }
@@ -439,6 +405,9 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
} }
try { try {
if (!status.listen) {
throw new Error("Daemon listen target is unavailable.");
}
const baseUrl = buildDaemonHttpBaseUrl(status.listen); const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) { if (!baseUrl) {
throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`); 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); const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) { if (!baseUrl) {
return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` }; return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` };
+8 -3
View File
@@ -107,7 +107,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
"server", "server",
"dist", "dist",
"scripts", "scripts",
"daemon-runner.js", "supervisor-entrypoint.js",
), ),
}), }),
execArgv: [], execArgv: [],
@@ -115,7 +115,12 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
} }
const serverPackage = resolveServerPackageInfo(); 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)) { if (existsSync(distRunner)) {
return { return {
entryPath: distRunner, entryPath: distRunner,
@@ -126,7 +131,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
return { return {
entryPath: assertPathExists({ entryPath: assertPathExists({
label: "Daemon runner source", 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"], execArgv: ["--import", "tsx"],
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.37", "version": "0.1.38",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"main": "build/index.js", "main": "build/index.js",
"types": "build/index.d.ts", "types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.37", "version": "0.1.38",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.37", "version": "0.1.38",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.37", "version": "0.1.38",
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.37", "@getpaseo/highlight": "0.1.38",
"@getpaseo/relay": "0.1.37", "@getpaseo/relay": "0.1.38",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
+1 -1
View File
@@ -7,7 +7,7 @@ dotenv.config({
quiet: true, 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( const result = spawnSync(
process.execPath, 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)], ["--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)],
@@ -3,7 +3,10 @@ import { describe, expect, test } from "vitest";
describe("supervision parity", () => { describe("supervision parity", () => {
test("has exactly one runtime callsite for runSupervisor", () => { 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 devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8");
const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length; const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length;
@@ -1,8 +1,12 @@
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { loadConfig } from "../src/server/config.js"; import {
import { acquirePidLock, PidLockError, releasePidLock } from "../src/server/pid-lock.js"; acquirePidLock,
PidLockError,
releasePidLock,
updatePidLock,
} from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { runSupervisor } from "./supervisor.js"; import { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.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 config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
const workerExecArgv = resolveWorkerExecArgv(workerEntry); const workerExecArgv = resolveWorkerExecArgv(workerEntry);
const workerEnv: NodeJS.ProcessEnv = { const workerEnv: NodeJS.ProcessEnv = { ...process.env, PASEO_SUPERVISED: "1" };
...process.env,
PASEO_PID_LOCK_MODE: "external",
};
const packagedNodeEntrypointRunner = const packagedNodeEntrypointRunner =
process.env.ELECTRON_RUN_AS_NODE === "1" process.env.ELECTRON_RUN_AS_NODE === "1"
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url)) ? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
@@ -84,10 +85,9 @@ async function main(): Promise<void> {
applySherpaLoaderEnv(workerEnv); applySherpaLoaderEnv(workerEnv);
const paseoHome = resolvePaseoHome(workerEnv); const paseoHome = resolvePaseoHome(workerEnv);
const daemonConfig = loadConfig(paseoHome, { env: workerEnv });
try { try {
await acquirePidLock(paseoHome, daemonConfig.listen, { await acquirePidLock(paseoHome, null, {
ownerPid: process.pid, ownerPid: process.pid,
}); });
} catch (error) { } catch (error) {
@@ -135,6 +135,9 @@ async function main(): Promise<void> {
}) })
: undefined, : undefined,
restartOnCrash: config.devMode, restartOnCrash: config.devMode,
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
},
onSupervisorExit: releaseLock, onSupervisorExit: releaseLock,
}); });
} }
+27 -1
View File
@@ -4,6 +4,10 @@ type WorkerLifecycleMessage =
| { | {
type: "paseo:shutdown"; type: "paseo:shutdown";
} }
| {
type: "paseo:ready";
listen: string;
}
| { | {
type: "paseo:restart"; type: "paseo:restart";
reason?: string; reason?: string;
@@ -21,6 +25,7 @@ type SupervisorOptions = {
args: string[]; args: string[];
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
} | null; } | null;
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
restartOnCrash?: boolean; restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void; onSupervisorExit?: () => Promise<void> | void;
}; };
@@ -37,6 +42,13 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
if (type === "paseo:shutdown") { if (type === "paseo:shutdown") {
return { 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") { if (type === "paseo:restart") {
const reason = (msg as { reason?: unknown }).reason; const reason = (msg as { reason?: unknown }).reason;
return { return {
@@ -110,6 +122,16 @@ export function runSupervisor(options: SupervisorOptions): void {
return; 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") { if (lifecycleMessage.type === "paseo:shutdown") {
requestShutdown("Shutdown requested by worker"); requestShutdown("Shutdown requested by worker");
return; return;
@@ -127,7 +149,11 @@ export function runSupervisor(options: SupervisorOptions): void {
return; return;
} }
if (restarting || (restartOnCrash && code !== 0 && code !== null)) { const crashed =
restartOnCrash &&
((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL"));
if (restarting || crashed) {
restarting = false; restarting = false;
log(`Worker exited (${exitDescriptor}). Restarting worker...`); log(`Worker exited (${exitDescriptor}). Restarting worker...`);
spawnWorker(); spawnWorker();
+27 -32
View File
@@ -39,13 +39,12 @@ import type {
ListCommandsResponse, ListCommandsResponse,
ListProviderModelsResponseMessage, ListProviderModelsResponseMessage,
ListAvailableProvidersResponse, ListAvailableProvidersResponse,
SpeechModelsListResponse,
SpeechModelsDownloadResponse,
ListTerminalsResponse, ListTerminalsResponse,
CreateTerminalResponse, CreateTerminalResponse,
SubscribeTerminalResponse, SubscribeTerminalResponse,
TerminalState, TerminalState,
KillTerminalResponse, KillTerminalResponse,
CaptureTerminalResponse,
TerminalInput, TerminalInput,
SessionInboundMessage, SessionInboundMessage,
SessionOutboundMessage, SessionOutboundMessage,
@@ -216,8 +215,6 @@ type FileExplorerPayload = FileExplorerResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick< type ListCommandsDraftConfig = Pick<
AgentSessionConfig, AgentSessionConfig,
@@ -240,6 +237,7 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"];
type CaptureTerminalPayload = CaptureTerminalResponse["payload"];
type ChatCreatePayload = Extract< type ChatCreatePayload = Extract<
SessionOutboundMessage, SessionOutboundMessage,
{ type: "chat/create/response" } { 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, requestId?: string): Promise<ListCommandsPayload>;
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>; async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
async listCommands( 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 resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "list_terminals_request", type: "list_terminals_request",
cwd, ...(cwd === undefined ? {} : { cwd }),
requestId: resolvedRequestId, requestId: resolvedRequestId,
}); });
return this.sendCorrelatedRequest({ 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> { async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
requestId: options.requestId, requestId: options.requestId,
+19 -34
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 { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
import type { RequestedSpeechProviders } from "./speech/speech-types.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 { AgentManager } from "./agent/agent-manager.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { createAgentMcpServer } from "./agent/mcp-server.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 { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.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 { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import { import {
createVoiceMcpSocketBridgeManager, createVoiceMcpSocketBridgeManager,
@@ -187,10 +186,6 @@ export type PaseoDaemonConfig = {
downloadTokenTtlMs?: number; downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap; agentProviderSettings?: AgentProviderRuntimeSettingsMap;
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pidLock?: {
mode?: "self" | "external";
ownerPid?: number;
};
}; };
export interface PaseoDaemon { export interface PaseoDaemon {
@@ -459,7 +454,6 @@ export async function createPaseoDaemon(
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
let wsServer: VoiceAssistantWebSocketServer | null = null; let wsServer: VoiceAssistantWebSocketServer | null = null;
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | 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) // Create in-memory transport for Session's Agent MCP client (voice assistant tools)
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => { const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
@@ -622,21 +616,12 @@ export async function createPaseoDaemon(
}); });
}, },
}); });
const { const speechService = createSpeechService({
resolveVoiceTurnDetection,
resolveVoiceStt,
resolveVoiceTts,
resolveDictationStt,
getSpeechReadiness,
subscribeSpeechReadiness,
cleanup: cleanupSpeechRuntime,
localModelConfig,
} = await initializeSpeechRuntime({
logger, logger,
openaiConfig: config.openai, openaiConfig: config.openai,
speechConfig: config.speech, speechConfig: config.speech,
}); });
logger.info({ elapsed: elapsed() }, "Speech runtime initialized"); logger.info({ elapsed: elapsed() }, "Speech service created");
wsServer = new VoiceAssistantWebSocketServer( wsServer = new VoiceAssistantWebSocketServer(
httpServer, httpServer,
@@ -648,7 +633,7 @@ export async function createPaseoDaemon(
config.paseoHome, config.paseoHome,
createInMemoryAgentMcpTransport, createInMemoryAgentMcpTransport,
{ allowedOrigins, allowedHosts: config.allowedHosts }, { allowedOrigins, allowedHosts: config.allowedHosts },
{ turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts }, speechService,
terminalManager, terminalManager,
{ {
voiceAgentMcpStdio: { voiceAgentMcpStdio: {
@@ -666,9 +651,6 @@ export async function createPaseoDaemon(
}, },
{ {
finalTimeoutMs: config.dictationFinalTimeoutMs, finalTimeoutMs: config.dictationFinalTimeoutMs,
stt: resolveDictationStt,
localModels: localModelConfig ?? undefined,
getSpeechReadiness,
}, },
config.agentProviderSettings, config.agentProviderSettings,
daemonVersion, daemonVersion,
@@ -686,9 +668,6 @@ export async function createPaseoDaemon(
scheduleService, scheduleService,
checkoutDiffManager, checkoutDiffManager,
); );
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot);
});
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); 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) { if (relayEnabled) {
const offer = await createConnectionOfferV2({ const offer = await createConnectionOfferV2({
serverId, serverId,
@@ -764,6 +753,10 @@ export async function createPaseoDaemon(
httpServer.listen(listenTarget.path); 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 () => { const stop = async () => {
@@ -773,9 +766,7 @@ export async function createPaseoDaemon(
runtimeSettings: config.agentProviderSettings, runtimeSettings: config.agentProviderSettings,
}); });
terminalManager.killAll(); terminalManager.killAll();
unsubscribeSpeechReadiness?.(); speechService.stop();
unsubscribeSpeechReadiness = null;
cleanupSpeechRuntime();
await scheduleService.stop().catch(() => undefined); await scheduleService.stop().catch(() => undefined);
await relayTransport?.stop().catch(() => undefined); await relayTransport?.stop().catch(() => undefined);
if (wsServer) { if (wsServer) {
@@ -792,12 +783,6 @@ export async function createPaseoDaemon(
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path); unlinkSync(listenTarget.path);
} }
// Release PID lock
if (ownsPidLock) {
await releasePidLock(config.paseoHome, {
ownerPid: pidLockOwnerPid,
});
}
}; };
return { return {
@@ -730,7 +730,7 @@ describe("daemon E2E", () => {
}); });
describe("archivePaseoWorktree", () => { 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 repoRoot = tmpCwd();
const { execSync } = await import("child_process"); const { execSync } = await import("child_process");
@@ -749,7 +749,7 @@ describe("daemon E2E", () => {
}); });
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); 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( writeFileSync(
path.join(repoRoot, "paseo.json"), path.join(repoRoot, "paseo.json"),
JSON.stringify({ JSON.stringify({
@@ -760,12 +760,12 @@ describe("daemon E2E", () => {
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', 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 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, cwd: repoRoot,
stdio: "pipe", stdio: "pipe",
}); });
@@ -813,8 +813,8 @@ describe("daemon E2E", () => {
expect(archive.removedAgents).toContain(agent.id); expect(archive.removedAgents).toContain(agent.id);
expect(existsSync(agent.cwd)).toBe(false); expect(existsSync(agent.cwd)).toBe(false);
expect(existsSync(destroyMarkerPath)).toBe(true); expect(existsSync(teardownMarkerPath)).toBe(true);
expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd); expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
expect(afterArchiveDirectories).not.toContain(agent.cwd); expect(afterArchiveDirectories).not.toContain(agent.cwd);
@@ -415,12 +415,17 @@ async function subscribeRawTerminal(
describe("daemon E2E terminal", () => { describe("daemon E2E terminal", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
let tempDirs: string[];
beforeEach(async () => { beforeEach(async () => {
ctx = await createDaemonTestContext(); ctx = await createDaemonTestContext();
tempDirs = [];
}); });
afterEach(async () => { afterEach(async () => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
await ctx.cleanup(); await ctx.cleanup();
}, 60000); }, 60000);
@@ -1090,4 +1095,161 @@ describe("daemon E2E terminal", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, 40000); }, 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",
},
]);
});
});
}); });
-4
View File
@@ -131,14 +131,10 @@ async function main() {
}; };
try { try {
const pidLockMode = process.env.PASEO_PID_LOCK_MODE === "external" ? "external" : "self";
daemon = await createPaseoDaemon( daemon = await createPaseoDaemon(
{ {
...config, ...config,
onLifecycleIntent: handleLifecycleIntent, onLifecycleIntent: handleLifecycleIntent,
pidLock: {
mode: pidLockMode,
},
}, },
logger, logger,
); );
+15 -3
View File
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, test } from "vitest"; 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", () => { describe("pid-lock ownership", () => {
test("writes and releases lock for explicit owner pid", async () => { test("writes and releases lock for explicit owner pid", async () => {
@@ -14,13 +14,25 @@ describe("pid-lock ownership", () => {
await ( await (
acquirePidLock as unknown as ( acquirePidLock as unknown as (
home: string, home: string,
sockPath: string, sockPath: string | null,
options: { ownerPid: number }, options: { ownerPid: number },
) => Promise<void> ) => Promise<void>
)(paseoHome, "127.0.0.1:6767", { ownerPid }); )(paseoHome, null, { ownerPid });
const lock = await getPidLockInfo(paseoHome); const lock = await getPidLockInfo(paseoHome);
expect(lock?.pid).toBe(ownerPid); 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 ( await (
releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise<void> releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise<void>
+33 -2
View File
@@ -8,7 +8,7 @@ export interface PidLockInfo {
startedAt: string; startedAt: string;
hostname: string; hostname: string;
uid: number; uid: number;
listen: string; listen: string | null;
} }
export class PidLockError extends Error { export class PidLockError extends Error {
@@ -43,7 +43,7 @@ function resolveOwnerPid(ownerPid?: number): number {
export async function acquirePidLock( export async function acquirePidLock(
paseoHome: string, paseoHome: string,
listen: string, listen: string | null,
options?: { ownerPid?: number }, options?: { ownerPid?: number },
): Promise<void> { ): Promise<void> {
const pidPath = getPidFilePath(paseoHome); 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( export async function releasePidLock(
paseoHome: string, paseoHome: string,
options?: { ownerPid?: number }, options?: { ownerPid?: number },
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ import type { PaseoSpeechConfig } from "../bootstrap.js";
import type { InitializedLocalSpeech } from "./providers/local/runtime.js"; import type { InitializedLocalSpeech } from "./providers/local/runtime.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js";
import type { TurnDetectionProvider } from "./turn-detection-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(() => ({ const { initializeLocalSpeechServicesMock } = vi.hoisted(() => ({
initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise<InitializedLocalSpeech>>(), initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise<InitializedLocalSpeech>>(),
@@ -70,7 +70,7 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe
return { providers }; return { providers };
} }
describe("initializeSpeechRuntime readiness", () => { describe("createSpeechService readiness", () => {
beforeEach(() => { beforeEach(() => {
initializeLocalSpeechServicesMock.mockReset(); initializeLocalSpeechServicesMock.mockReset();
}); });
@@ -92,7 +92,7 @@ describe("initializeSpeechRuntime readiness", () => {
cleanup: () => {}, cleanup: () => {},
}); });
const runtime = await initializeSpeechRuntime({ const runtime = createSpeechService({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
speechConfig: createSpeechConfig({ speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: true, explicit: true }, dictationStt: { provider: "local", enabled: true, explicit: true },
@@ -101,14 +101,16 @@ describe("initializeSpeechRuntime readiness", () => {
voiceTts: { provider: "local", enabled: false, explicit: true }, 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.dictation.available).toBe(true);
expect(readiness.realtimeVoice.reasonCode).toBe("disabled"); expect(readiness.realtimeVoice.reasonCode).toBe("disabled");
expect(readiness.voiceFeature.available).toBe(true); expect(readiness.voiceFeature.available).toBe(true);
expect(readiness.voiceFeature.reasonCode).toBe("ready"); expect(readiness.voiceFeature.reasonCode).toBe("ready");
runtime.cleanup(); runtime.stop();
}); });
it("keeps voice feature available when only realtime voice is enabled and ready", async () => { it("keeps voice feature available when only realtime voice is enabled and ready", async () => {
@@ -130,7 +132,7 @@ describe("initializeSpeechRuntime readiness", () => {
cleanup: () => {}, cleanup: () => {},
}); });
const runtime = await initializeSpeechRuntime({ const runtime = createSpeechService({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
speechConfig: createSpeechConfig({ speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: false, explicit: true }, dictationStt: { provider: "local", enabled: false, explicit: true },
@@ -139,13 +141,15 @@ describe("initializeSpeechRuntime readiness", () => {
voiceTts: { provider: "local", enabled: true, explicit: true }, 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.realtimeVoice.available).toBe(true);
expect(readiness.dictation.reasonCode).toBe("disabled"); expect(readiness.dictation.reasonCode).toBe("disabled");
expect(readiness.voiceFeature.available).toBe(true); expect(readiness.voiceFeature.available).toBe(true);
expect(readiness.voiceFeature.reasonCode).toBe("ready"); expect(readiness.voiceFeature.reasonCode).toBe("ready");
runtime.cleanup(); runtime.stop();
}); });
}); });
@@ -336,25 +336,23 @@ function resolveEffectiveProviderIds(params: {
}; };
} }
export type InitializedSpeechRuntime = { export type SpeechService = {
resolveVoiceTurnDetection: () => TurnDetectionProvider | null; resolveStt: () => SpeechToTextProvider | null;
resolveVoiceStt: () => SpeechToTextProvider | null; resolveTts: () => TextToSpeechProvider | null;
resolveVoiceTts: () => TextToSpeechProvider | null; resolveTurnDetection: () => TurnDetectionProvider | null;
resolveDictationStt: () => SpeechToTextProvider | null; resolveDictationStt: () => SpeechToTextProvider | null;
getSpeechReadiness: () => SpeechReadinessSnapshot; getReadiness: () => SpeechReadinessSnapshot;
subscribeSpeechReadiness: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void; onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
cleanup: () => void; start: () => void;
localModelConfig: { stop: () => void;
modelsDir: string; ready: Promise<void>;
defaultModelIds: LocalSpeechModelId[];
} | null;
}; };
export async function initializeSpeechRuntime(params: { export function createSpeechService(params: {
logger: Logger; logger: Logger;
openaiConfig?: PaseoOpenAIConfig; openaiConfig?: PaseoOpenAIConfig;
speechConfig?: PaseoSpeechConfig; speechConfig?: PaseoSpeechConfig;
}): Promise<InitializedSpeechRuntime> { }): SpeechService {
const logger = params.logger.child({ module: "speech-runtime" }); const logger = params.logger.child({ module: "speech-runtime" });
const speechConfig = params.speechConfig ?? null; const speechConfig = params.speechConfig ?? null;
const openaiConfig = params.openaiConfig; const openaiConfig = params.openaiConfig;
@@ -397,6 +395,14 @@ export async function initializeSpeechRuntime(params: {
const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>(); const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>();
let lastReadinessFingerprint: string | null = null; let lastReadinessFingerprint: string | null = null;
let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | 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 computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
const realtimeVoice = buildRealtimeVoiceReadiness({ const realtimeVoice = buildRealtimeVoiceReadiness({
@@ -658,16 +664,36 @@ export async function initializeSpeechRuntime(params: {
} }
}; };
await runReconcile(); const start = (): void => {
const snapshot = computeReadinessSnapshot(); if (started || stopped) {
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { return;
if (missingLocalModelIds.length > 0) {
startBackgroundDownload();
} }
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; stopped = true;
if (monitorTimeout) { if (monitorTimeout) {
clearTimeout(monitorTimeout); clearTimeout(monitorTimeout);
@@ -677,13 +703,14 @@ export async function initializeSpeechRuntime(params: {
}; };
return { return {
resolveVoiceTurnDetection: () => turnDetectionService, resolveTurnDetection: () => turnDetectionService,
resolveVoiceStt: () => sttService, resolveStt: () => sttService,
resolveVoiceTts: () => ttsService, resolveTts: () => ttsService,
resolveDictationStt: () => dictationSttService, resolveDictationStt: () => dictationSttService,
getSpeechReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(), getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
subscribeSpeechReadiness, onReadinessChange: subscribeSpeechReadiness,
cleanup, start,
localModelConfig, stop,
ready,
}; };
} }
+22 -33
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 type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js"; import { PushService } from "./push/push-service.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.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 { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import { import {
computeShouldNotifyClient, computeShouldNotifyClient,
@@ -244,18 +240,10 @@ export class VoiceAssistantWebSocketServer {
private readonly pushTokenStore: PushTokenStore; private readonly pushTokenStore: PushTokenStore;
private readonly pushService: PushService; private readonly pushService: PushService;
private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly stt: Resolvable<SpeechToTextProvider | null>; private readonly speech: SpeechService | null;
private readonly tts: Resolvable<TextToSpeechProvider | null>;
private readonly turnDetection: Resolvable<TurnDetectionProvider | null>;
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly dictation: { private readonly dictation: {
finalTimeoutMs?: number; finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
localModels?: {
modelsDir: string;
defaultModelIds: LocalSpeechModelId[];
};
getSpeechReadiness?: () => SpeechReadinessSnapshot;
} | null; } | null;
private readonly voice: { private readonly voice: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
@@ -289,6 +277,7 @@ export class VoiceAssistantWebSocketServer {
private readonly inboundSessionRequestCounts = new Map<string, number>(); private readonly inboundSessionRequestCounts = new Map<string, number>();
private readonly requestLatencies = new Map<string, number[]>(); private readonly requestLatencies = new Map<string, number[]>();
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null; private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private unsubscribeSpeechReadiness: (() => void) | null = null;
constructor( constructor(
server: HTTPServer, server: HTTPServer,
@@ -300,11 +289,7 @@ export class VoiceAssistantWebSocketServer {
paseoHome: string, paseoHome: string,
createAgentMcpTransport: AgentMcpTransportFactory, createAgentMcpTransport: AgentMcpTransportFactory,
wsConfig: WebSocketServerConfig, wsConfig: WebSocketServerConfig,
speech?: { speech?: SpeechService | null,
turnDetection: Resolvable<TurnDetectionProvider | null>;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
},
terminalManager?: TerminalManager | null, terminalManager?: TerminalManager | null,
voice?: { voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
@@ -313,12 +298,6 @@ export class VoiceAssistantWebSocketServer {
}, },
dictation?: { dictation?: {
finalTimeoutMs?: number; finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
localModels?: {
modelsDir: string;
defaultModelIds: LocalSpeechModelId[];
};
getSpeechReadiness?: () => SpeechReadinessSnapshot;
}, },
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
daemonVersion?: string, daemonVersion?: string,
@@ -359,17 +338,18 @@ export class VoiceAssistantWebSocketServer {
this.downloadTokenStore = downloadTokenStore; this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome; this.paseoHome = paseoHome;
this.createAgentMcpTransport = createAgentMcpTransport; this.createAgentMcpTransport = createAgentMcpTransport;
this.turnDetection = speech?.turnDetection ?? null; this.speech = speech ?? null;
this.stt = speech?.stt ?? null;
this.tts = speech?.tts ?? null;
this.terminalManager = terminalManager ?? null; this.terminalManager = terminalManager ?? null;
this.voice = voice ?? null; this.voice = voice ?? null;
this.dictation = dictation ?? null; this.dictation = dictation ?? null;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.onLifecycleIntent = onLifecycleIntent ?? null; this.onLifecycleIntent = onLifecycleIntent ?? null;
this.serverCapabilities = buildServerCapabilities({ 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" }); const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json")); this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
@@ -460,6 +440,8 @@ export class VoiceAssistantWebSocketServer {
} }
public async close(): Promise<void> { public async close(): Promise<void> {
this.unsubscribeSpeechReadiness?.();
this.unsubscribeSpeechReadiness = null;
if (this.runtimeMetricsInterval) { if (this.runtimeMetricsInterval) {
clearInterval(this.runtimeMetricsInterval); clearInterval(this.runtimeMetricsInterval);
this.runtimeMetricsInterval = null; this.runtimeMetricsInterval = null;
@@ -661,12 +643,12 @@ export class VoiceAssistantWebSocketServer {
scheduleService: this.scheduleService, scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager, checkoutDiffManager: this.checkoutDiffManager,
createAgentMcpTransport: this.createAgentMcpTransport, createAgentMcpTransport: this.createAgentMcpTransport,
stt: this.stt, stt: () => this.speech?.resolveStt() ?? null,
tts: this.tts, tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
voice: { voice: {
...(this.voice ?? {}), ...(this.voice ?? {}),
turnDetection: this.turnDetection, turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
}, },
voiceBridge: { voiceBridge: {
registerVoiceSpeakHandler: (agentId, handler) => { registerVoiceSpeakHandler: (agentId, handler) => {
@@ -684,7 +666,14 @@ export class VoiceAssistantWebSocketServer {
ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent, ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent,
removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent, 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, agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
}); });
@@ -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})`);
}
}
+25 -49
View File
@@ -739,17 +739,6 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({
requestId: z.string(), 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({ export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"), type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema, handle: AgentPersistenceHandleSchema,
@@ -1141,7 +1130,7 @@ export const RegisterPushTokenMessageSchema = z.object({
export const ListTerminalsRequestSchema = z.object({ export const ListTerminalsRequestSchema = z.object({
type: z.literal("list_terminals_request"), type: z.literal("list_terminals_request"),
cwd: z.string(), cwd: z.string().optional(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -1200,6 +1189,15 @@ export const KillTerminalRequestSchema = z.object({
requestId: z.string(), 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", [ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
VoiceAudioChunkMessageSchema, VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema, AbortRequestMessageSchema,
@@ -1220,8 +1218,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CreateAgentRequestMessageSchema, CreateAgentRequestMessageSchema,
ListProviderModelsRequestMessageSchema, ListProviderModelsRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema, ListAvailableProvidersRequestMessageSchema,
SpeechModelsListRequestSchema,
SpeechModelsDownloadRequestSchema,
ResumeAgentRequestMessageSchema, ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema, RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema, CancelAgentRequestMessageSchema,
@@ -1265,6 +1261,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema,
TerminalInputSchema, TerminalInputSchema,
KillTerminalRequestSchema, KillTerminalRequestSchema,
CaptureTerminalRequestSchema,
ChatCreateRequestSchema, ChatCreateRequestSchema,
ChatListRequestSchema, ChatListRequestSchema,
ChatInspectRequestSchema, 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({ const AgentSlashCommandSchema = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@@ -2209,7 +2178,7 @@ export const TerminalStateSchema = z
export const ListTerminalsResponseSchema = z.object({ export const ListTerminalsResponseSchema = z.object({
type: z.literal("list_terminals_response"), type: z.literal("list_terminals_response"),
payload: z.object({ payload: z.object({
cwd: z.string(), cwd: z.string().optional(),
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
requestId: z.string(), 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({ export const TerminalStreamExitSchema = z.object({
type: z.literal("terminal_stream_exit"), type: z.literal("terminal_stream_exit"),
payload: z.object({ payload: z.object({
@@ -2321,14 +2300,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileDownloadTokenResponseSchema, FileDownloadTokenResponseSchema,
ListProviderModelsResponseMessageSchema, ListProviderModelsResponseMessageSchema,
ListAvailableProvidersResponseSchema, ListAvailableProvidersResponseSchema,
SpeechModelsListResponseSchema,
SpeechModelsDownloadResponseSchema,
ListCommandsResponseSchema, ListCommandsResponseSchema,
ListTerminalsResponseSchema, ListTerminalsResponseSchema,
TerminalsChangedSchema, TerminalsChangedSchema,
CreateTerminalResponseSchema, CreateTerminalResponseSchema,
SubscribeTerminalResponseSchema, SubscribeTerminalResponseSchema,
KillTerminalResponseSchema, KillTerminalResponseSchema,
CaptureTerminalResponseSchema,
TerminalStreamExitSchema, TerminalStreamExitSchema,
ChatCreateResponseSchema, ChatCreateResponseSchema,
ChatListResponseSchema, ChatListResponseSchema,
@@ -2391,8 +2369,6 @@ export type ListProviderModelsResponseMessage = z.infer<
typeof ListProviderModelsResponseMessageSchema typeof ListProviderModelsResponseMessageSchema
>; >;
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>; 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 ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>; export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>; 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 LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>; export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>; 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 ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>; export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>; 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 TerminalState = z.infer<typeof TerminalStateSchema>;
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>; export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>; 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>; export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// ============================================================================ // ============================================================================
+66
View File
@@ -5,6 +5,7 @@ import { chmodSync, existsSync, statSync } from "node:fs";
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import stripAnsi from "strip-ansi";
import type { TerminalCell, TerminalState } from "../shared/messages.js"; import type { TerminalCell, TerminalState } from "../shared/messages.js";
const { Terminal } = xterm; const { Terminal } = xterm;
@@ -62,6 +63,17 @@ interface BuildTerminalEnvironmentInput {
env: Record<string, string>; env: Record<string, string>;
} }
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
type EnsureNodePtySpawnHelperExecutableOptions = { type EnsureNodePtySpawnHelperExecutableOptions = {
packageRoot?: string; packageRoot?: string;
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
@@ -426,6 +438,60 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] {
return lines.slice(-limit); 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> { export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const { const {
cwd, cwd,
+64 -25
View File
@@ -11,7 +11,9 @@ import {
runWorktreeSetupCommands, runWorktreeSetupCommands,
slugify, slugify,
} from "./worktree"; } from "./worktree";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import {
getPaseoWorktreeMetadataPath,
} from "./worktree-metadata.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
import { dirname, join } from "path"; import { dirname, join } from "path";
@@ -527,69 +529,106 @@ describe("paseo worktree manager", () => {
expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false); 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 = { const paseoConfig = {
worktree: { worktree: {
destroy: [ teardown: [
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', 'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.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)); writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'", { execSync(
cwd: repoDir, "git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'",
}); {
cwd: repoDir,
},
);
const created = await createWorktree({ const created = await createWorktree({
branchName: "destroy-branch", branchName: "teardown-branch",
cwd: repoDir, cwd: repoDir,
baseBranch: "main", baseBranch: "main",
worktreeSlug: "destroy-test", worktreeSlug: "teardown-test",
paseoHome, paseoHome,
}); });
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: created.worktreePath,
branchName: created.branchName,
});
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
expect(existsSync(created.worktreePath)).toBe(false); expect(existsSync(created.worktreePath)).toBe(false);
const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8"); const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8");
expect(destroyLog).toContain(`source=${repoDir}`); expect(teardownLog).toContain(`source=${repoDir}`);
expect(destroyLog).toContain(`root_alias=${repoDir}`); expect(teardownLog).toContain(`root_alias=${repoDir}`);
expect(destroyLog).toContain(`worktree=${created.worktreePath}`); expect(teardownLog).toContain(`worktree=${created.worktreePath}`);
expect(destroyLog).toContain("branch=destroy-branch"); 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 = { const paseoConfig = {
worktree: { worktree: {
destroy: [ teardown: [
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"', '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", "echo boom 1>&2; exit 9",
], ],
}, },
}; };
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync( 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 }, { cwd: repoDir },
); );
const created = await createWorktree({ const created = await createWorktree({
branchName: "destroy-failure-branch", branchName: "teardown-failure-branch",
cwd: repoDir, cwd: repoDir,
baseBranch: "main", baseBranch: "main",
worktreeSlug: "destroy-failure-test", worktreeSlug: "teardown-failure-test",
paseoHome, paseoHome,
}); });
await expect( await expect(
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }), 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(created.worktreePath)).toBe(true);
expect(existsSync(join(repoDir, "destroy-start.log"))).toBe(true); expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true);
}); });
}); });
+25 -23
View File
@@ -17,7 +17,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
interface PaseoConfig { interface PaseoConfig {
worktree?: { worktree?: {
setup?: string[]; setup?: string[];
destroy?: string[]; teardown?: string[];
terminals?: WorktreeTerminalConfig[]; terminals?: WorktreeTerminalConfig[];
}; };
} }
@@ -94,14 +94,14 @@ export class WorktreeSetupError extends Error {
} }
} }
export type WorktreeDestroyCommandResult = WorktreeSetupCommandResult; export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult;
export class WorktreeDestroyError extends Error { export class WorktreeTeardownError extends Error {
readonly results: WorktreeDestroyCommandResult[]; readonly results: WorktreeTeardownCommandResult[];
constructor(message: string, results: WorktreeDestroyCommandResult[]) { constructor(message: string, results: WorktreeTeardownCommandResult[]) {
super(message); super(message);
this.name = "WorktreeDestroyError"; this.name = "WorktreeTeardownError";
this.results = results; this.results = results;
} }
} }
@@ -150,13 +150,13 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] {
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); 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 config = readPaseoConfig(repoRoot);
const destroyCommands = config?.worktree?.destroy; const teardownCommands = config?.worktree?.teardown;
if (!destroyCommands || destroyCommands.length === 0) { if (!teardownCommands || teardownCommands.length === 0) {
return []; 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[] { 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; worktreePath: string;
branchName?: string; branchName?: string;
repoRootPath?: string; repoRootPath?: string;
}): Promise<WorktreeDestroyCommandResult[]> { }): Promise<WorktreeTeardownCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo) // Read paseo.json from the worktree (it will have the same content as the source repo)
const destroyCommands = getWorktreeDestroyCommands(options.worktreePath); const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
if (destroyCommands.length === 0) { if (teardownCommands.length === 0) {
return []; return [];
} }
@@ -521,30 +521,32 @@ export async function runWorktreeDestroyCommands(options: {
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const branchName = const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
const destroyEnv = { const teardownEnv: NodeJS.ProcessEnv = {
...process.env, ...process.env,
// Source checkout path is the original git repo root (shared across worktrees), not the // 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 // worktree itself. This allows lifecycle scripts to copy or clean resources using paths
// source checkout. // from the source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias. // Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath, PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath, PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName, PASEO_BRANCH_NAME: branchName,
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
}; };
const results: WorktreeDestroyCommandResult[] = []; const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of destroyCommands) { for (const cmd of teardownCommands) {
const result = await execSetupCommand(cmd, { const result = await execSetupCommand(cmd, {
cwd: options.worktreePath, cwd: options.worktreePath,
env: destroyEnv, env: teardownEnv,
}); });
results.push(result); results.push(result);
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new WorktreeDestroyError( throw new WorktreeTeardownError(
`Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(), `Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(),
results, results,
); );
} }
@@ -884,7 +886,7 @@ export async function deletePaseoWorktree({
throw new Error("Refusing to delete non-Paseo worktree"); throw new Error("Refusing to delete non-Paseo worktree");
} }
await runWorktreeDestroyCommands({ await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree, worktreePath: resolvedWorktree,
}); });
+1 -1
View File
@@ -19,6 +19,6 @@
"declaration": false, "declaration": false,
"sourceMap": true "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"] "exclude": ["node_modules", "dist"]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.37", "version": "0.1.38",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -300,8 +300,8 @@ function MultiProviderSection() {
return ( return (
<FeatureSection <FeatureSection
title="Multi-provider" title="Use the best agent for the job"
description="Escape the vendor lock, mix and match frontier models through a single interface." 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"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{providers.map((p) => ( {providers.map((p) => (
@@ -320,10 +320,10 @@ function MultiProviderSection() {
function SelfHostedDiagram() { function SelfHostedDiagram() {
const clients = [ 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: "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="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: "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="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: "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="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: "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 hosts = ["MacBook Pro", "Hetzner VM", "Dev server"];
const containerRef = React.useRef<HTMLDivElement>(null); 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="md:hidden flex flex-col items-center gap-4 py-4">
<div className="space-y-2 w-full"> <div className="space-y-2 w-full">
{clients.map((c) => ( {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"> <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">
{c.icon} <span className="text-white/80">{c.icon}</span>
{c.name} <span className="font-medium">{c.name}</span>
</div> </div>
))} ))}
</div> </div>
@@ -391,14 +391,14 @@ function SelfHostedDiagram() {
<div className="w-px h-6 border-l border-dashed border-white/25" /> <div className="w-px h-6 border-l border-dashed border-white/25" />
<div className="space-y-2 w-full"> <div className="space-y-2 w-full">
{hosts.map((h) => ( {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"> <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">
<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="2" width="20" height="8" rx="2" />
<rect x="2" y="14" 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="6" r="1" />
<circle cx="6" cy="18" r="1" /> <circle cx="6" cy="18" r="1" />
</svg> </svg></span>
{h} <span className="font-medium">{h}</span>
</div> </div>
))} ))}
</div> </div>
@@ -419,10 +419,10 @@ function SelfHostedDiagram() {
<div <div
key={c.name} key={c.name}
ref={(el) => { clientRefs.current[i] = el; }} 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} <span className="text-white/80">{c.icon}</span>
{c.name} <span className="font-medium">{c.name}</span>
</div> </div>
))} ))}
</div> </div>
@@ -431,10 +431,10 @@ function SelfHostedDiagram() {
<div className="flex-1" /> <div className="flex-1" />
{/* Center label */} {/* 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"> <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-xs font-medium text-white/50">E2E Encrypted Relay</p> <p className="text-sm font-medium text-white/50">E2E Encrypted Relay</p>
<p className="text-[10px] text-white/25">or</p> <p className="text-xs text-white/25">or</p>
<p className="text-xs font-medium text-white/50">Direct Connection</p> <p className="text-sm font-medium text-white/50">Direct Connection</p>
</div> </div>
{/* Spacer */} {/* Spacer */}
@@ -446,15 +446,15 @@ function SelfHostedDiagram() {
<div <div
key={h} key={h}
ref={(el) => { hostRefs.current[i] = el; }} 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="2" width="20" height="8" rx="2" />
<rect x="2" y="14" 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="6" r="1" />
<circle cx="6" cy="18" r="1" /> <circle cx="6" cy="18" r="1" />
</svg> </svg></span>
{h} <span className="font-medium">{h}</span>
</div> </div>
))} ))}
</div> </div>
@@ -466,8 +466,8 @@ function SelfHostedDiagram() {
function SelfHostedSection() { function SelfHostedSection() {
return ( return (
<FeatureSection <FeatureSection
title="Self-hosted" title="Your agents, every surface"
description="Run the daemon wherever you want and connect however you want. Orchestrate agents in multiple hosts from a single interface." 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 /> <SelfHostedDiagram />
</FeatureSection> </FeatureSection>
+51 -4
View File
@@ -96,7 +96,7 @@ function Worktrees() {
{/* paseo.json */} {/* paseo.json */}
<section className="space-y-4"> <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"> <p className="text-white/60 leading-relaxed">
When Paseo creates a worktree, it's a fresh checkout. Dependencies aren't installed, 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{" "} 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 the worktree is created. Use it to install dependencies, copy local config files, or run
any other initialization. any other initialization.
</p> </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"> <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{" "} <strong>Important:</strong> Setup commands come from{" "}
<code className="font-mono">paseo.json</code> in the selected base branch. If you pick{" "} <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"> <section className="space-y-4">
<h2 className="text-xl font-medium">Environment variables</h2> <h2 className="text-xl font-medium">Environment variables</h2>
<p className="text-white/60 leading-relaxed"> <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> </p>
<ul className="text-white/60 space-y-2 list-disc list-inside"> <ul className="text-white/60 space-y-2 list-disc list-inside">
<li> <li>
@@ -148,8 +162,8 @@ function Worktrees() {
<code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created <code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created
</li> </li>
<li> <li>
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — an available local port for <code className="font-mono">$PASEO_WORKTREE_PORT</code> — the worktree port, when
setup scripts runtime metadata exists
</li> </li>
</ul> </ul>
<p className="text-white/60 leading-relaxed"> <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 shouldn't be in git (like <code className="font-mono">.env</code>) from your source
checkout to the worktree. checkout to the worktree.
</p> </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> </section>
{/* Common patterns */} {/* Common patterns */}
+53
View File
@@ -154,6 +154,59 @@ paseo chat wait <room> --timeout <duration>
paseo chat delete <name-or-id> 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 ## Available Models
**Claude (default provider)** — use aliases, CLI resolves to latest version: **Claude (default provider)** — use aliases, CLI resolves to latest version: