Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 952e9e2974 fix(cli): clarify daemon executable status label 2026-07-06 16:40:40 +02:00
Mohamed Boudra 887845418e refactor(desktop): share daemon pid lock reader 2026-07-06 16:38:50 +02:00
Mohamed Boudra f7c4ef774d fix(daemon): derive desktop management from executable
Desktop management now follows the daemon runtime path, so daemons started through the bundled CLI remain eligible for desktop upgrade restarts. New locks record executablePath; the old desktopManaged lock bit is accepted only for compatibility with existing locks.
2026-07-06 16:19:00 +02:00
Mohamed Boudra 08a0d0c28d Fix worktree setup scripts losing PATH (#1908)
* fix(worktree): preserve PATH for lifecycle scripts

Lifecycle command strings were running through login shells, which could rewrite PATH before setup commands saw the daemon environment. Use the shared stable script-shell helper without login startup files, and route related project-authored command strings through it.

* fix(worktree): ignore Bash startup env hook

Project command strings should not source shell startup hooks before they see Paseo's supplied environment. Strip BASH_ENV from lifecycle, loop verify, and ACP string-command shells and pin the behavior in setup tests.

* fix(worktree): resolve Bash through PATH

* fix(worktree): preserve Windows cmd command strings

* test(acp): expect Windows cmd terminal strings
2026-07-06 20:34:42 +08:00
Xisheng Parker Zhao 61df450366 fix(server): surface TRAE CLI slash commands and skills over ACP (#1896)
traecli publishes its slash commands and skills asynchronously via the standard
ACP available_commands_update notification ~400ms after session/new resolves.
Because the catalog entry runs through the plain GenericACPAgentClient
(waitForInitialCommands defaults to false), listCommands() resolves before that
first batch arrives and the Paseo UI shows an empty slash menu — intermittently,
depending on whether the menu is opened inside the ~400ms race window.

Add a thin TraeACPAgentClient that sets waitForInitialCommands: true (10s
timeout) and wire it into the extends:"acp" derived-provider branch, mirroring
CursorACPAgentClient exactly. Cursor has the identical async-commands behavior;
this reuses that adopted pattern rather than introducing anything provider-
specific. Unlike Kiro (#1792), traecli uses the standard available_commands_update
update type, so no extensionCommandsParser is needed.
2026-07-06 20:30:22 +08:00
paseo-ai[bot] 5d25ed6bf8 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-06 08:03:12 +00:00
Mohamed Boudra 391cf75ece chore(release): cut 0.1.104-beta.4 2026-07-06 09:58:20 +02:00
Mohamed Boudra 7ee2d6fd7e docs: changelog for v0.1.104-beta.4 2026-07-06 09:56:20 +02:00
Mohamed Boudra b1d11fccd5 Hide browser tools unless enabled
Browser tools are now hidden from agent tool catalogs unless daemon browser tools are enabled.
2026-07-05 19:02:59 +02:00
paseo-ai[bot] 05f8dda1e3 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-05 16:11:29 +00:00
Mohamed Boudra 9968f2ba50 Speed up inbound WebSocket validation (#1895)
* perf(protocol): generate inbound ws validators

* perf(client): use generated ws validation

* docs(protocol): document generated validation

* fix(protocol): make validator generation source-only

* test(protocol): cover explicit provider model normalization

* fix(protocol): preserve inbound compat defaults

* fix(protocol): make validator import rewrite portable

* fix(protocol): harden validation safety checks

* fix(protocol): guard generated validator boundaries

* fix(protocol): normalize legacy inbound defaults

* fix(protocol): simplify generated validation safety net

* fix(protocol): keep cold typecheck source-only

* fix(protocol): encapsulate validator codegen

* fix(protocol): bootstrap source-alias typechecks

* fix(app): keep source aliases out of bundler config

* fix(protocol): harden validator codegen packaging

* fix(protocol): own zod-aot patches in generator

* fix(protocol): trim validator safety net

* fix(client): normalize provider updates before dispatch

* fix(protocol): keep validator generation out of install
2026-07-06 00:07:54 +08:00
paseo-ai[bot] 0ecf2fb8da fix: update lockfile signatures and Nix hash [skip ci] 2026-07-04 08:26:52 +00:00
68 changed files with 1627 additions and 342 deletions
-6
View File
@@ -76,7 +76,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build server stack
run: npm run build:server
@@ -113,7 +112,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
@@ -170,7 +168,6 @@ jobs:
}
Start-Sleep -Seconds (20 * $attempt)
}
- name: Build server stack
run: npm run build:server
@@ -202,7 +199,6 @@ jobs:
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -227,7 +223,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build client dependencies
run: npm run build:client
@@ -270,7 +265,6 @@ jobs:
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
-1
View File
@@ -27,7 +27,6 @@ jobs:
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build app dependencies
run: npm run build:app-deps
+1
View File
@@ -84,6 +84,7 @@ valknut-report.json/
.plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
packages/protocol/src/generated/validation/*.aot.ts
/artifacts
packages/desktop/.cache/
+2 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.1.104-beta.3 - 2026-07-04
## 0.1.104-beta.4 - 2026-07-06
### Added
@@ -14,6 +14,7 @@
### Improved
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
+5 -1
View File
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
@@ -92,10 +93,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
- New fields: `.optional()` with a sensible default.
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
+24
View File
@@ -37,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
---
## Runtime PID Lock
**Path:** `$PASEO_HOME/paseo.pid`
The daemon writes this file when it starts and removes it when it exits cleanly.
It is a runtime lock, not a durable settings file.
| Field | Type | Description |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `pid` | `number` | Owner process PID for stop/status operations |
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
| `hostname` | `string` | Host that created the lock |
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
Desktop-managed is a read-time classification by the desktop app: the daemon
executable must be the desktop app executable or live inside the same desktop
install. The CLI reports the raw `executablePath` but does not try to classify
it because an npm-installed CLI has no authoritative desktop install root.
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
+9
View File
@@ -178,6 +178,15 @@ defaults. The default rotation is `10m` x `3` files everywhere.
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
```json
{
"worktree": {
+42
View File
@@ -0,0 +1,42 @@
# Protocol Validation
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
## Runtime Path
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
## Codegen Ownership
The protocol package owns generation.
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
## Regression Tests
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
- discriminated-union branch output must propagate `.default()` fields
- current sequential item routing must accept `tool_call`-like status branches
- generated runtime imports must keep `.js` extensions for packaged Node ESM
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
## Schema Purity
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
+1 -1
View File
@@ -1 +1 @@
sha256-2QXdrDE327fIF5THUve9NRk6Jx1gSUIWaNc7aTOrX+s=
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
+49 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -21797,9 +21797,9 @@
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -28563,9 +28563,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -35069,6 +35069,26 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-aot": {
"version": "0.20.4",
"resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz",
"integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"get-tsconfig": "^4.14.0",
"jiti": "^2.7.0",
"picomatch": "^4.0.4",
"unplugin": "^3.0.0"
},
"bin": {
"zod-aot": "dist/cli/index.js"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
@@ -35132,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36150,12 +36170,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.3",
"@getpaseo/protocol": "0.1.104-beta.3",
"@getpaseo/server": "0.1.104-beta.3",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36401,10 +36421,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.3",
"@getpaseo/relay": "0.1.104-beta.3",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36415,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36658,7 +36678,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37554,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37786,19 +37806,20 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38016,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.3",
"@getpaseo/highlight": "0.1.104-beta.3",
"@getpaseo/protocol": "0.1.104-beta.3",
"@getpaseo/relay": "0.1.104-beta.3",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/highlight": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38561,7 +38582,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -58,7 +58,7 @@
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"private": true,
"main": "index.ts",
"scripts": {
@@ -341,7 +341,7 @@ const CATALOG_DATA = [
description: "ByteDance's official TRAE coding agent with native ACP support",
version: "manual",
iconId: "traecli",
installLink: "https://docs.trae.cn/cli",
installLink: "https://docs.trae.cn/cli_get-started-with-trae-cli",
command: ["traecli", "acp", "serve"],
},
{
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.3",
"@getpaseo/protocol": "0.1.104-beta.3",
"@getpaseo/server": "0.1.104-beta.3",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -25,6 +25,7 @@ export interface LocalDaemonPidInfo {
hostname?: string;
uid?: number;
listen?: string;
executablePath?: string;
desktopManaged?: boolean;
}
@@ -255,6 +256,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
listen: resolveListenField(parsed.listen, parsed.sockPath),
executablePath: typeof parsed.executablePath === "string" ? parsed.executablePath : undefined,
desktopManaged: parsed.desktopManaged === true ? true : undefined,
};
} catch {
@@ -0,0 +1,99 @@
import type { Command } from "commander";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { CommandOptions } from "../../output/index.js";
import { runStatusCommand } from "./status.js";
const mocks = vi.hoisted(() => ({
execCommand: vi.fn(async () => ({ stdout: process.execPath, stderr: "" })),
findExecutable: vi.fn(async () => null),
getOrCreateServerId: vi.fn(() => "srv_test"),
loadConfig: vi.fn(() => ({
listen: "127.0.0.1:6767",
relayEnabled: true,
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: false,
relayPublicUseTls: false,
})),
resolvePaseoHome: vi.fn((env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo"),
spawnProcess: vi.fn(),
}));
vi.mock("@getpaseo/server", () => ({
execCommand: mocks.execCommand,
findExecutable: mocks.findExecutable,
getOrCreateServerId: mocks.getOrCreateServerId,
loadConfig: mocks.loadConfig,
resolvePaseoHome: mocks.resolvePaseoHome,
spawnProcess: mocks.spawnProcess,
}));
const tempRoots: string[] = [];
async function createStatusHome(pidLock: Record<string, unknown>): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-status-"));
tempRoots.push(root);
const home = path.join(root, ".paseo");
await mkdir(home, { recursive: true });
await writeFile(path.join(home, "paseo.pid"), JSON.stringify(pidLock));
return home;
}
async function readStatusJson(home: string): Promise<Record<string, unknown>> {
const previousPath = process.env.PATH;
try {
process.env.PATH = "";
const result = await runStatusCommand({ home } as CommandOptions, {} as Command);
return result.schema.serialize?.(result.data[0]) as Record<string, unknown>;
} finally {
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
}
}
describe("daemon status desktop management fields", () => {
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
test("reports executable identity without deriving desktop management in the CLI", async () => {
const executablePath =
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper";
const home = await createStatusHome({
pid: process.pid,
startedAt: "2026-07-06T00:00:00.000Z",
hostname: "dev-host",
uid: 501,
listen: "/tmp/paseo-status.sock",
executablePath,
});
await expect(readStatusJson(home)).resolves.toMatchObject({
daemonExecutablePath: executablePath,
desktopManaged: false,
});
});
test("keeps reporting legacy desktopManaged locks for old readers", async () => {
const home = await createStatusHome({
pid: process.pid,
startedAt: "2026-07-06T00:00:00.000Z",
hostname: "dev-host",
uid: 501,
listen: "/tmp/paseo-status.sock",
desktopManaged: true,
});
await expect(readStatusJson(home)).resolves.toMatchObject({
daemonExecutablePath: null,
desktopManaged: true,
});
});
});
@@ -28,6 +28,7 @@ interface DaemonStatus {
owner: string | null;
logPath: string;
daemonNode: string;
daemonExecutablePath: string | null;
cliNode: string;
cliVersion: string;
daemonVersion: string | null;
@@ -126,6 +127,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
{ key: "Owner", value: status.owner ?? "-" },
{ key: "Logs", value: status.logPath },
{ key: "Daemon Node", value: status.daemonNode },
{ key: "PID Lock Executable", value: status.daemonExecutablePath ?? "-" },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
@@ -405,9 +407,11 @@ export async function runStatusCommand(
owner,
logPath: state.logPath,
daemonNode,
daemonExecutablePath: state.pidInfo?.executablePath ?? null,
cliNode,
cliVersion,
daemonVersion,
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
desktopManaged: state.pidInfo?.desktopManaged === true,
providers,
note,
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -27,7 +27,7 @@
},
"scripts": {
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "tsc -p tsconfig.json --incremental false",
"build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false",
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.3",
"@getpaseo/relay": "0.1.104-beta.3",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -0,0 +1,74 @@
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type {
GetProvidersSnapshotResponseMessage,
ListProviderModelsResponseMessage,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type ProvidersSnapshotUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>["payload"];
// COMPAT(model-normalize): daemon normalizes at source (provider-registry) — shim covers older daemons; drop when floor >= v0.1.104
function normalizeAgentModels(
models: AgentModelDefinition[] | undefined,
): AgentModelDefinition[] | undefined {
if (!models) {
return models;
}
let changed = false;
const normalized = models.map((model) => {
const next = normalizeAgentModelDefinition(model);
changed ||= next !== model;
return next;
});
return changed ? normalized : models;
}
function normalizeProviderSnapshotEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry {
const models = normalizeAgentModels(entry.models);
return models === entry.models ? entry : { ...entry, models };
}
function normalizeProviderSnapshotEntries(
entries: ProviderSnapshotEntry[],
): ProviderSnapshotEntry[] {
let changed = false;
const normalized = entries.map((entry) => {
const next = normalizeProviderSnapshotEntry(entry);
changed ||= next !== entry;
return next;
});
return changed ? normalized : entries;
}
export function normalizeListProviderModelsPayload(
payload: ListProviderModelsPayload,
): ListProviderModelsPayload {
const models = normalizeAgentModels(payload.models);
return models === payload.models ? payload : { ...payload, models };
}
export function normalizeProvidersSnapshotPayload<
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
>(payload: T): T {
const entries = normalizeProviderSnapshotEntries(payload.entries);
return entries === payload.entries ? payload : { ...payload, entries };
}
export function normalizeProviderSnapshotUpdateMessage(
msg: SessionOutboundMessage,
): SessionOutboundMessage {
if (msg.type !== "providers_snapshot_update") {
return msg;
}
return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) };
}
+23 -13
View File
@@ -13,8 +13,8 @@ import {
DaemonUpdateResponseSchema,
SessionInboundMessageSchema,
type ServerInfoStatusPayload,
WSOutboundMessageSchema,
} from "@getpaseo/protocol/messages";
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
import type {
AgentStreamEventPayload,
AgentSnapshotPayload,
@@ -118,6 +118,11 @@ import {
type WebSocketFactory,
} from "./daemon-client-transport.js";
import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
import {
normalizeListProviderModelsPayload,
normalizeProviderSnapshotUpdateMessage,
normalizeProvidersSnapshotPayload,
} from "./compat/normalize-provider-models.js";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
import type {
BrowserAutomationExecuteRequest,
@@ -370,6 +375,7 @@ type WriteProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "write_project_config_response" }
>["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
@@ -3728,7 +3734,7 @@ export class DaemonClient {
provider: AgentProvider,
options?: { cwd?: string; requestId?: string },
): Promise<ListProviderModelsPayload> {
return this.sendCorrelatedSessionRequest({
const payload = await this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_models_request",
@@ -3739,6 +3745,7 @@ export class DaemonClient {
// Provider SDK cold starts (especially model discovery) can exceed 60s.
timeout: 90000,
});
return normalizeListProviderModelsPayload(payload);
}
async listProviderModes(
@@ -3788,7 +3795,7 @@ export class DaemonClient {
cwd?: string;
requestId?: string;
}): Promise<GetProvidersSnapshotPayload> {
return this.sendCorrelatedSessionRequest({
const payload = await this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "get_providers_snapshot_request",
@@ -3796,6 +3803,7 @@ export class DaemonClient {
},
responseType: "get_providers_snapshot_response",
});
return normalizeProvidersSnapshotPayload(payload);
}
async getDaemonConfig(
@@ -4754,7 +4762,7 @@ export class DaemonClient {
return;
}
const parsed = WSOutboundMessageSchema.safeParse(parsedJson);
const parsed = validateWSOutboundMessage(parsedJson);
if (!parsed.success) {
const msgType =
parsedJson != null &&
@@ -5017,8 +5025,10 @@ export class DaemonClient {
}
private handleSessionMessage(msg: SessionOutboundMessage): void {
if (msg.type === "status") {
const serverInfo = parseServerInfoStatusPayload(msg.payload);
const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg);
if (consumerMessage.type === "status") {
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
if (serverInfo) {
this.lastServerInfoMessage = serverInfo;
if (this.connectionState.status === "connecting") {
@@ -5034,39 +5044,39 @@ export class DaemonClient {
}
}
if (msg.type === "terminal_stream_exit") {
this.terminalStreams.removeTerminal(msg.payload.terminalId);
if (consumerMessage.type === "terminal_stream_exit") {
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
}
if (this.rawMessageListeners.size > 0) {
for (const handler of this.rawMessageListeners) {
try {
handler(msg);
handler(consumerMessage);
} catch {
// no-op
}
}
}
const handlers = this.messageHandlers.get(msg.type);
const handlers = this.messageHandlers.get(consumerMessage.type);
if (handlers) {
for (const handler of handlers) {
try {
handler(msg);
handler(consumerMessage);
} catch {
// no-op
}
}
}
const event = this.toEvent(msg);
const event = this.toEvent(consumerMessage);
if (event) {
for (const handler of this.eventListeners) {
handler(event);
}
}
this.resolveWaiters(msg);
this.resolveWaiters(consumerMessage);
}
private resolveWaiters(msg: SessionOutboundMessage): void {
+18 -1
View File
@@ -629,20 +629,37 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
});
const snapshotUpdates: string[] = [];
const snapshotModelDefaults: Array<string | undefined> = [];
const unsubscribe = client.providers.subscribe((update) => {
snapshotUpdates.push(update.generatedAt);
snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId);
});
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
entries: [
{
provider: "codex",
status: "ready",
enabled: true,
models: [
{
provider: "codex",
id: "gpt-5.5",
label: "GPT-5.5",
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
},
],
},
],
generatedAt: "2026-05-16T01:00:00.000Z",
},
}),
);
expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]);
expect(snapshotModelDefaults).toEqual(["high"]);
unsubscribe();
await client.close();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -7,6 +7,8 @@ import { createDaemonCommandHandlers } from "./daemon-manager";
const mocks = vi.hoisted(() => ({
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
desktopExecutablePath: "/opt/Paseo/Paseo",
managedDaemonExecutablePath: "/opt/Paseo/resources/Paseo",
settings: {
releaseChannel: "stable",
daemon: {
@@ -23,7 +25,9 @@ const mocks = vi.hoisted(() => ({
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
getPath: vi.fn((name: string) =>
name === "exe" ? mocks.desktopExecutablePath : "/tmp/paseo-user-data",
),
getVersion: vi.fn(() => "1.2.3"),
isPackaged: true,
},
@@ -91,6 +95,30 @@ function createMockChildProcess(): MockChildProcess {
return child;
}
function pidLockPath(): string {
return `${mocks.paseoHome}/paseo.pid`;
}
function writePidLock(input: {
pid: number;
executablePath?: string;
desktopManaged?: boolean;
}): void {
mkdirSync(mocks.paseoHome, { recursive: true });
writeFileSync(pidLockPath(), JSON.stringify(input));
}
function writeManagedPidLock(pid: number): void {
writePidLock({
pid,
executablePath: mocks.managedDaemonExecutablePath,
});
}
function removePidLock(): void {
rmSync(pidLockPath(), { force: true });
}
function scheduleFailedStartup(child: MockChildProcess): void {
setImmediate(() => {
child.emit("exit", 1, null);
@@ -150,7 +178,54 @@ describe("daemon-manager commands", () => {
expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
});
it("derives desktop management from the pid lock executable path", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 4242,
listen: "127.0.0.1:6767",
daemonVersion: "1.2.3",
desktopManaged: false,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.desktop_daemon_status()).resolves.toEqual({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: null,
pid: 4242,
home: mocks.paseoHome,
version: "1.2.3",
desktopManaged: true,
error: null,
});
});
it("falls back to the legacy desktopManaged lock field for old locks", async () => {
writePidLock({ pid: 4242, desktopManaged: true });
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 4242,
listen: "127.0.0.1:6767",
daemonVersion: "1.2.3",
desktopManaged: false,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.desktop_daemon_status()).resolves.toMatchObject({
status: "running",
pid: 4242,
desktopManaged: true,
});
});
it("routes running desktop daemon stops through external CLI daemon stop", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "running",
@@ -159,7 +234,10 @@ describe("daemon-manager commands", () => {
listen: "127.0.0.1:6767",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
serverId: "",
@@ -226,6 +304,7 @@ describe("daemon-manager commands", () => {
});
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "stale_pid",
@@ -236,7 +315,10 @@ describe("daemon-manager commands", () => {
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
connectedDaemon: "unreachable",
@@ -269,6 +351,7 @@ describe("daemon-manager commands", () => {
});
it("records the renderer stop reason when stopping the desktop daemon", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "running",
@@ -277,7 +360,10 @@ describe("daemon-manager commands", () => {
listen: "127.0.0.1:6767",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped", reason: "lifecycle_shutdown_rpc" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
serverId: "",
@@ -302,6 +388,7 @@ describe("daemon-manager commands", () => {
});
it("uses a stale reachable desktop daemon when the version matches", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
@@ -330,6 +417,7 @@ describe("daemon-manager commands", () => {
});
it("restarts a stale reachable desktop daemon when the version differs", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "stale_pid",
@@ -350,21 +438,27 @@ describe("daemon-manager commands", () => {
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
})
.mockResolvedValueOnce({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-2",
pid: 8888,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.3",
desktopManaged: true,
.mockImplementationOnce(async () => {
writeManagedPidLock(8888);
return {
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-2",
pid: 8888,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.3",
desktopManaged: true,
};
});
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
const handlers = createDaemonCommandHandlers();
+34 -8
View File
@@ -39,6 +39,7 @@ import {
import type { DesktopSettings } from "../settings/desktop-settings.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const STARTUP_POLL_INTERVAL_MS = 200;
@@ -123,18 +124,43 @@ function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
}
export function isDesktopManagedDaemonRunningSync(): boolean {
function deriveDesktopManagedFromPidLock(lock: Record<string, unknown>): boolean {
const executablePath = toTrimmedString(lock.executablePath);
if (executablePath) {
return deriveDesktopManagedFromExecutablePath({
daemonExecutablePath: executablePath,
desktopExecutablePath: app.getPath("exe"),
platform: process.platform,
});
}
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
return lock.desktopManaged === true;
}
function readPidLockRecord(): Record<string, unknown> | null {
try {
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown };
if (lock.desktopManaged !== true) return false;
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
return isProcessRunning(lock.pid);
const lock = JSON.parse(raw) as unknown;
return isRecord(lock) ? lock : null;
} catch {
return false;
return null;
}
}
function readDesktopManagedFromPidLock(): boolean {
const lock = readPidLockRecord();
return lock !== null && deriveDesktopManagedFromPidLock(lock);
}
export function isDesktopManagedDaemonRunningSync(): boolean {
const lock = readPidLockRecord();
if (lock === null) return false;
if (!deriveDesktopManagedFromPidLock(lock)) return false;
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
return isProcessRunning(lock.pid);
}
function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record<string, unknown> {
return {
status: status.status,
@@ -276,7 +302,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
const hasRunningLocalProcess = localDaemon === "running";
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
const desktopManaged = payload.desktopManaged === true;
const desktopManaged = readDesktopManagedFromPidLock();
const apiReachable = connectedDaemon === "reachable";
let status: DesktopDaemonState = "stopped";
if (apiReachable || hasRunningLocalProcess) {
@@ -418,7 +444,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
detached: true,
envMode: "internal",
env: invocation.env,
envOverlay: { PASEO_DESKTOP_MANAGED: "1", PASEO_WEB_UI_ENABLED: "false" },
envOverlay: { PASEO_WEB_UI_ENABLED: "false" },
stdio: ["ignore", "ignore", "ignore"],
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed";
describe("desktop managed daemon executable derivation", () => {
it("treats the macOS Helper inside the app bundle as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
daemonExecutablePath:
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
platform: "darwin",
}),
).toBe(true);
});
it("rejects a macOS Helper from a different app bundle", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
daemonExecutablePath:
"/Applications/Other.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
platform: "darwin",
}),
).toBe(false);
});
it("treats executables inside the Windows install directory as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
daemonExecutablePath:
"c:\\users\\me\\appdata\\local\\programs\\paseo\\resources\\Paseo.exe",
platform: "win32",
}),
).toBe(true);
});
it("rejects system Node on Windows", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
daemonExecutablePath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
}),
).toBe(false);
});
it("treats Linux executables from the same install directory as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/opt/Paseo/Paseo",
daemonExecutablePath: "/opt/Paseo/resources/Paseo",
platform: "linux",
}),
).toBe(true);
});
it("rejects npm-installed CLI daemon executables", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/opt/Paseo/Paseo",
daemonExecutablePath: "/usr/local/bin/node",
platform: "linux",
}),
).toBe(false);
});
});
@@ -0,0 +1,69 @@
import path from "node:path";
interface DesktopManagedInput {
daemonExecutablePath: string | null | undefined;
desktopExecutablePath: string;
platform: NodeJS.Platform;
}
function pathModuleForPlatform(platform: NodeJS.Platform): typeof path.win32 | typeof path.posix {
return platform === "win32" ? path.win32 : path.posix;
}
function normalizeForComparison(filePath: string, platform: NodeJS.Platform): string {
const pathModule = pathModuleForPlatform(platform);
const normalized = pathModule.normalize(filePath.trim());
return platform === "win32" ? normalized.toLowerCase() : normalized;
}
function resolveMacAppRoot(filePath: string): string | null {
const marker = ".app/";
const markerIndex = filePath.indexOf(marker);
if (markerIndex === -1) {
return filePath.endsWith(".app") ? filePath : null;
}
return filePath.slice(0, markerIndex + ".app".length);
}
function resolveDesktopInstallRoot(input: {
desktopExecutablePath: string;
platform: NodeJS.Platform;
}): string {
const normalized = normalizeForComparison(input.desktopExecutablePath, input.platform);
if (input.platform === "darwin") {
return resolveMacAppRoot(normalized) ?? path.posix.dirname(normalized);
}
return pathModuleForPlatform(input.platform).dirname(normalized);
}
function isSamePathOrInside(input: {
candidatePath: string;
parentPath: string;
platform: NodeJS.Platform;
}): boolean {
const pathModule = pathModuleForPlatform(input.platform);
const candidatePath = normalizeForComparison(input.candidatePath, input.platform);
const parentPath = normalizeForComparison(input.parentPath, input.platform);
const relative = pathModule.relative(parentPath, candidatePath);
return (
relative === "" ||
(relative.length > 0 && !relative.startsWith("..") && !pathModule.isAbsolute(relative))
);
}
export function deriveDesktopManagedFromExecutablePath(input: DesktopManagedInput): boolean {
if (!input.daemonExecutablePath?.trim()) {
return false;
}
const desktopInstallRoot = resolveDesktopInstallRoot({
desktopExecutablePath: input.desktopExecutablePath,
platform: input.platform,
});
return isSamePathOrInside({
candidatePath: input.daemonExecutablePath,
parentPath: desktopInstallRoot,
platform: input.platform,
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"files": [
"dist",
"!dist/**/*.map"
+7
View File
@@ -0,0 +1,7 @@
# Protocol Validator Codegen
This directory is build-time only. `ws-outbound.compile.ts` is the zod-aot discovery entry for the inbound WebSocket validator.
The generated runtime file is written to `../src/generated/validation/ws-outbound.aot.ts` and is not committed. The protocol package owns every generation trigger through its npm lifecycle scripts.
`zod-aot` is exact-pinned, and the protocol generator applies the small compiler patches it requires before generation. Treat changes to those patches like compiler changes: regenerate, inspect the output, and run the protocol validation regression tests before shipping.
@@ -0,0 +1,4 @@
import { compile } from "zod-aot";
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../src/messages.js";
export const WSOutboundMessageSchema = compile(SourceWSOutboundMessageSchema);
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -23,7 +23,12 @@
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit",
"test": "vitest run"
"test": "vitest run",
"generate:validators": "node scripts/generate-validation-aot.mjs",
"watch": "concurrently --kill-others --names validation,tsc --prefix-colors green,yellow \"node scripts/watch-validation-aot.mjs\" \"tsc -p tsconfig.json --watch --preserveWatchOutput\"",
"prebuild": "npm run generate:validators",
"pretypecheck": "npm run generate:validators",
"pretest": "npm run generate:validators"
},
"dependencies": {
"zod": "^4.4.3"
@@ -31,6 +36,7 @@
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
}
}
@@ -0,0 +1,106 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const source = resolve(packageRoot, "codegen/ws-outbound.compile.ts");
const runtimeSchemaMetadata = resolve(packageRoot, "src/validation/ws-outbound-schema-metadata.ts");
const output = resolve(packageRoot, "src/generated/validation/ws-outbound.aot.ts");
const require = createRequire(import.meta.url);
const zodAotEntry = require.resolve("zod-aot");
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
const emitterPath = resolve(zodAotRoot, "dist/cli/emitter.js");
const discriminatedUnionPath = resolve(
zodAotRoot,
"dist/core/codegen/schemas/discriminated-union.js",
);
async function ensureZodAotRuntimeImportExtensionPatch() {
const emitter = await readFile(emitterPath, "utf8");
if (emitter.includes('sourceRelPath.endsWith(".js")')) {
return;
}
const before = 'let importPath = sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");';
const after =
'let importPath = sourceRelPath.endsWith(".js")\n ? sourceRelPath\n : sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");';
if (!emitter.includes(before)) {
throw new Error("zod-aot emitter shape changed; update the runtime import extension patch");
}
await writeFile(emitterPath, emitter.replace(before, after));
}
async function ensureZodAotDiscriminatedUnionOutputPatch() {
let discriminatedUnionEmitter = await readFile(discriminatedUnionPath, "utf8");
if (
discriminatedUnionEmitter.includes(
"const needsOutputPropagation = ir.options.some(hasMutation);",
)
) {
return;
}
const importBefore = 'import { escapeString } from "../context.js";';
const importAfter = 'import { escapeString, hasMutation } from "../context.js";';
const outputFlagBefore = "const discKey = escapeString(ir.discriminator);\n let code = emit `";
const outputFlagAfter =
"const discKey = escapeString(ir.discriminator);\n const needsOutputPropagation = ir.options.some(hasMutation);\n let code = emit `";
const propagationBefore =
" ${g.visit(option, { input: objVar, output: objVar })}\n break;`;";
const propagationAfter =
' ${g.visit(option, { input: objVar, output: objVar })}\n ${needsOutputPropagation ? `${g.output}=${objVar};` : ""}\n break;`;';
if (
!discriminatedUnionEmitter.includes(importBefore) ||
!discriminatedUnionEmitter.includes(outputFlagBefore) ||
!discriminatedUnionEmitter.includes(propagationBefore)
) {
throw new Error("zod-aot discriminated-union emitter shape changed; update the output patch");
}
discriminatedUnionEmitter = discriminatedUnionEmitter
.replace(importBefore, importAfter)
.replace(outputFlagBefore, outputFlagAfter)
.replace(propagationBefore, propagationAfter);
await writeFile(discriminatedUnionPath, discriminatedUnionEmitter);
}
await Promise.all([
ensureZodAotRuntimeImportExtensionPatch(),
ensureZodAotDiscriminatedUnionOutputPatch(),
]);
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
const schemas = await discoverSchemas(source, { cacheBust: true });
if (schemas.length === 0) {
throw new Error(`No zod-aot compile() exports found in ${relative(packageRoot, source)}`);
}
const compiled = compileSchemas(schemas, { mode: "inline" });
const runtimeImportPath = relative(dirname(output), runtimeSchemaMetadata)
.replace(/\.[cm]?[jt]sx?$/, ".js")
.split(sep)
.join("/");
const content = generateCompiledFileContent(compiled, runtimeImportPath, {
zodCompat: false,
}).replace(
"// AUTO-GENERATED by zod-aot — DO NOT EDIT",
"// @ts-nocheck\n// AUTO-GENERATED by zod-aot — DO NOT EDIT",
);
await mkdir(dirname(output), { recursive: true });
await writeFile(output, content);
console.info(
`generated ${relative(packageRoot, output)} from ${relative(packageRoot, source)} (${schemas
.map((schema) => schema.exportName)
.join(", ")})`,
);
@@ -0,0 +1,96 @@
import { readdir, stat } from "node:fs/promises";
import { dirname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const protocolSrcDir = join(packageRoot, "src");
const pollIntervalMs = 1000;
let currentFingerprint = "";
let generateInFlight = false;
let generateAgain = false;
async function collectSourceFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const path = join(dir, entry.name);
const relativePath = relative(protocolSrcDir, path);
const parts = relativePath.split(sep);
if (parts[0] === "generated") {
continue;
}
if (entry.isDirectory()) {
files.push(...(await collectSourceFiles(path)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".ts")) {
files.push(path);
}
}
return files;
}
async function fingerprintSources() {
const files = await collectSourceFiles(protocolSrcDir);
const stats = await Promise.all(
files.sort().map(async (file) => {
const fileStat = await stat(file);
return `${relative(packageRoot, file)}:${fileStat.mtimeMs}:${fileStat.size}`;
}),
);
return stats.join("\n");
}
function runGenerate() {
if (generateInFlight) {
generateAgain = true;
return;
}
generateInFlight = true;
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const child = spawn(npmCommand, ["run", "generate:validators"], {
cwd: packageRoot,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
generateInFlight = false;
if (code !== 0) {
console.error(
signal
? `validation AOT generation stopped by ${signal}`
: `validation AOT generation failed with exit code ${code}`,
);
}
if (generateAgain) {
generateAgain = false;
runGenerate();
}
});
}
async function poll() {
try {
const nextFingerprint = await fingerprintSources();
if (currentFingerprint && nextFingerprint !== currentFingerprint) {
runGenerate();
}
currentFingerprint = nextFingerprint;
} catch (error) {
console.error("validation AOT watcher failed to scan protocol sources", error);
}
}
currentFingerprint = await fingerprintSources();
console.log("Watching protocol schema sources for validation AOT changes...");
setInterval(poll, pollIntervalMs);
@@ -0,0 +1,11 @@
# Generated protocol validators
`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/ws-outbound.compile.ts`.
Run:
```bash
npm run generate:validators --workspace=@getpaseo/protocol
```
The generated TypeScript is intentionally gitignored. zod-aot is exact-pinned because this is a young solo-maintainer project; keep the generated validator behind protocol regression tests when upgrading it.
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { AgentProviderSchema } from "../provider-manifest.js";
export const LoopLogEntrySchema = z.object({
seq: z.number().int().positive(),
@@ -38,41 +38,6 @@ describe("provider snapshot message schemas", () => {
expect(parsed.enabled).toBe(true);
});
test("normalizes thinking option defaults on provider snapshot models", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "claude",
status: "ready",
models: [
{
provider: "claude",
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
],
},
],
});
expect(parsed.models).toEqual([
{
provider: "claude",
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
],
defaultThinkingOptionId: "max",
},
]);
});
test("defaults missing enabled state in providers snapshot response entries", () => {
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
type: "get_providers_snapshot_response",
+47 -41
View File
@@ -2,9 +2,9 @@ import { z } from "zod";
import { TerminalActivitySchema } from "./terminal-activity.js";
import { CLIENT_CAPS } from "./client-capabilities.js";
import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { normalizeAgentModelDefinition, TOOL_CALL_ICON_NAMES } from "./agent-types.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
import { AgentProviderSchema } from "./provider-manifest.js";
import { TOOL_CALL_ICON_NAMES } from "./agent-types.js";
import {
ChatCreateRequestSchema,
ChatListRequestSchema,
@@ -40,7 +40,7 @@ import {
ScheduleDeleteResponseSchema,
ScheduleRunOnceResponseSchema,
ScheduleUpdateResponseSchema,
} from "@getpaseo/protocol/schedule/rpc-schemas";
} from "./schedule/rpc-schemas.js";
import {
LoopRunRequestSchema,
LoopListRequestSchema,
@@ -52,7 +52,7 @@ import {
LoopInspectResponseSchema,
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "@getpaseo/protocol/loop/rpc-schemas";
} from "./loop/rpc-schemas.js";
import {
BrowserAutomationExecuteRequestSchema,
BrowserAutomationExecuteResponseSchema,
@@ -73,7 +73,7 @@ import {
type PaseoMetadataGenerationEntry,
type PaseoScriptEntryRaw,
type ProjectConfigRpcError,
} from "@getpaseo/protocol/paseo-config-schema";
} from "./paseo-config-schema.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
@@ -247,19 +247,17 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureSelectSchema,
]);
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z
.object({
provider: AgentProviderSchema,
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
contextWindowMaxTokens: z.number().optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
})
.transform(normalizeAgentModelDefinition);
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
provider: AgentProviderSchema,
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
contextWindowMaxTokens: z.number().optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
});
export const ProviderSnapshotEntrySchema = z.object({
provider: AgentProviderSchema,
@@ -361,20 +359,21 @@ const AgentPermissionActionSchema = z.object({
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
});
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
z.object({
behavior: z.literal("allow"),
selectedActionId: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
selectedActionId: z.string().optional(),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
]);
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
z.discriminatedUnion("behavior", [
z.object({
behavior: z.literal("allow"),
selectedActionId: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
selectedActionId: z.string().optional(),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
]);
export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionRequest, unknown> =
z.object({
@@ -554,13 +553,16 @@ const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({
error: z.null(),
});
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown> = z.union([
ToolCallRunningPayloadSchema,
ToolCallCompletedPayloadSchema,
ToolCallFailedPayloadSchema,
ToolCallCanceledPayloadSchema,
]);
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown> =
z.discriminatedUnion("status", [
ToolCallRunningPayloadSchema,
ToolCallCompletedPayloadSchema,
ToolCallFailedPayloadSchema,
ToolCallCanceledPayloadSchema,
]);
// zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting
// the inner tool_call branch from the generated outer dispatch.
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknown> = z.union([
z.object({
type: z.literal("user_message"),
@@ -3113,7 +3115,9 @@ export const SetDaemonConfigResponseMessageSchema = z.object({
export const ReadProjectConfigResponseMessageSchema = z.object({
type: z.literal("read_project_config_response"),
payload: z.discriminatedUnion("ok", [
// zod-aot 0.2.0 miscompiles boolean discriminators as string options
// (`"true"`/`"false"`), so keep this sequential until upstream fixes it.
payload: z.union([
z.object({
requestId: z.string(),
repoRoot: z.string(),
@@ -3132,7 +3136,9 @@ export const ReadProjectConfigResponseMessageSchema = z.object({
export const WriteProjectConfigResponseMessageSchema = z.object({
type: z.literal("write_project_config_response"),
payload: z.discriminatedUnion("ok", [
// zod-aot 0.2.0 miscompiles boolean discriminators as string options
// (`"true"`/`"false"`), so keep this sequential until upstream fixes it.
payload: z.union([
z.object({
requestId: z.string(),
repoRoot: z.string(),
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { AgentProviderSchema } from "../provider-manifest.js";
export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]);
export type ScheduleStatus = z.infer<typeof ScheduleStatusSchema>;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ToolCallTimelineItem } from "./agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
import { stripCwdPrefix } from "./path-utils.js";
export type ToolCallDisplayInput = Pick<
@@ -0,0 +1,3 @@
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../messages.js";
export const WSOutboundMessageSchema = { schema: SourceWSOutboundMessageSchema };
@@ -0,0 +1,19 @@
import type { z } from "zod";
import { WSOutboundMessageSchema } from "../generated/validation/ws-outbound.aot.js";
import type { WSOutboundMessage } from "../messages.js";
type WSOutboundValidationResult =
| { success: true; data: WSOutboundMessage }
| { success: false; error: z.ZodError };
interface WSOutboundGeneratedValidator {
safeParse(input: unknown): WSOutboundValidationResult;
}
// zod-aot emits runtime JavaScript from WSOutboundMessageSchema but not its TypeScript surface.
// Protocol regression tests cover the patched compiler cases behind this boundary.
const wsOutboundValidator = WSOutboundMessageSchema as WSOutboundGeneratedValidator;
export function validateWSOutboundMessage(input: unknown): WSOutboundValidationResult {
return wsOutboundValidator.safeParse(input);
}
@@ -0,0 +1,145 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createJiti } from "jiti";
import { describe, expect, it } from "vitest";
import { WSOutboundMessageSchema as GeneratedWSOutboundMessageSchema } from "../../src/generated/validation/ws-outbound.aot.js";
interface GeneratedSchema {
safeParse(input: unknown): { success: boolean; data?: unknown };
}
const protocolRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const generatedWSOutboundPath = resolve(
protocolRoot,
"src/generated/validation/ws-outbound.aot.ts",
);
const require = createRequire(import.meta.url);
async function compileInlineSchema(sourceSchema: string): Promise<GeneratedSchema> {
const scratchRoot = resolve(protocolRoot, "../../.tmp");
await mkdir(scratchRoot, { recursive: true });
const tempDir = await mkdtemp(join(scratchRoot, "paseo-zod-aot-"));
try {
const sourcePath = join(tempDir, "schema.source.js");
const outputPath = join(tempDir, "schema.generated.ts");
await writeFile(join(tempDir, "package.json"), '{"type":"module"}\n');
await writeFile(
sourcePath,
[
'import { z } from "zod";',
'import { compile } from "zod-aot";',
sourceSchema,
"export const Schema = compile(SourceSchema);",
"",
].join("\n"),
);
const zodAotEntry = require.resolve("zod-aot");
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
const schemas = await discoverSchemas(sourcePath, { cacheBust: true });
const compiled = compileSchemas(schemas, { mode: "inline" });
const content = generateCompiledFileContent(compiled, "./schema.source.js", {
zodCompat: false,
});
await writeFile(outputPath, content);
const jiti = createJiti(import.meta.url, { moduleCache: false });
const generated = await jiti.import(outputPath);
return generated.Schema as GeneratedSchema;
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
describe("WS outbound zod-aot validation", () => {
it("applies defaults inside discriminated-union branches", async () => {
const schema = await compileInlineSchema(`
const SourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("with_default"),
enabled: z.boolean().default(true),
}),
z.object({
type: z.literal("without_default"),
label: z.string(),
}),
]);
`);
expect(schema.safeParse({ type: "with_default" })).toMatchObject({
success: true,
data: { type: "with_default", enabled: true },
});
});
it("routes tool-call-like status unions through the current sequential item union", async () => {
const schema = await compileInlineSchema(`
const ToolCallItemSchema = z.discriminatedUnion("status", [
z.object({ type: z.literal("tool_call"), status: z.literal("running"), callId: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("completed"), callId: z.string(), output: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("failed"), callId: z.string(), error: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("canceled"), callId: z.string() }),
]);
const TimelineItemSchema = z.union([
z.object({ type: z.literal("assistant_message"), text: z.string() }),
ToolCallItemSchema,
]);
const SourceSchema = z.object({
item: TimelineItemSchema,
});
`);
expect(
schema.safeParse({ item: { type: "tool_call", status: "running", callId: "run" } }),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "running", callId: "run" } },
});
expect(
schema.safeParse({
item: { type: "tool_call", status: "completed", callId: "done", output: "ok" },
}),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "completed", callId: "done", output: "ok" } },
});
expect(
schema.safeParse({
item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" },
}),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" } },
});
expect(
schema.safeParse({ item: { type: "tool_call", status: "canceled", callId: "stop" } }),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "canceled", callId: "stop" } },
});
});
it("accepts a minimal valid envelope and rejects a corrupted envelope", () => {
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "pong" }).success).toBe(true);
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "not_a_message" }).success).toBe(
false,
);
});
it("emits runtime imports with .js extensions", async () => {
const generated = await readFile(generatedWSOutboundPath, "utf8");
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,10 +65,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.3",
"@getpaseo/highlight": "0.1.104-beta.3",
"@getpaseo/protocol": "0.1.104-beta.3",
"@getpaseo/relay": "0.1.104-beta.3",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/highlight": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -766,6 +766,7 @@ describe("browser MCP tools", () => {
agentStorage: agentStorage as AgentStorage,
providerSnapshotManager:
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
browserToolsEnabled: true,
browserToolsBroker: broker as BrowserToolsBroker,
callerAgentId: "agent-1",
logger,
@@ -839,6 +840,7 @@ describe("browser MCP tools", () => {
agentStorage: agentStorage as AgentStorage,
providerSnapshotManager:
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
browserToolsEnabled: true,
browserToolsBroker: broker as BrowserToolsBroker,
callerAgentId: "agent-1",
logger,
@@ -893,50 +895,24 @@ describe("browser MCP tools", () => {
}
});
it("keeps browser tools registered when browser tools are disabled", async () => {
it("does not register browser tools when browser tools are disabled", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "agent-1",
cwd: REPO_CWD,
workspaceId: BROWSER_WORKSPACE_ID,
});
const execute = vi.fn().mockResolvedValue({
requestId: "req-browser-disabled",
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled.",
retryable: false,
},
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsBroker: { execute } as never,
browserToolsEnabled: false,
callerAgentId: "agent-1",
logger,
});
const tool = registeredTool(server, "browser_list_tabs");
const response = await tool.handler({});
expect(lookupTool(server, "browser_snapshot")).not.toBeUndefined();
expect(execute).toHaveBeenCalledWith({
agentId: "agent-1",
cwd: REPO_CWD,
workspaceId: BROWSER_WORKSPACE_ID,
command: { command: "list_tabs", args: {} },
});
expect(response.structuredContent).toEqual({
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled.",
retryable: false,
},
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: BROWSER_WORKSPACE_ID },
});
expect(lookupTool(server, "browser_list_tabs")).toBeUndefined();
expect(lookupTool(server, "browser_snapshot")).toBeUndefined();
});
it("wires browser tools through the browser tools broker", async () => {
@@ -955,6 +931,7 @@ describe("browser MCP tools", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsEnabled: true,
browserToolsBroker: { execute } as never,
callerAgentId: "agent-1",
logger,
@@ -994,6 +971,7 @@ describe("browser MCP tools", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsEnabled: true,
browserToolsBroker: { execute } as never,
callerAgentId: "agent-1",
logger,
@@ -25,6 +25,11 @@ const mockState = vi.hoisted(() => {
env?: Record<string, string>;
providerParams?: unknown;
}>,
trae: [] as Array<{
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}>,
pi: [] as ConstructorEntry[],
genericAcp: [] as Array<{
command: string[];
@@ -41,6 +46,7 @@ const mockState = vi.hoisted(() => {
this.constructorArgs.codex = [];
this.constructorArgs.copilot = [];
this.constructorArgs.cursor = [];
this.constructorArgs.trae = [];
this.constructorArgs.pi = [];
this.constructorArgs.genericAcp = [];
this.isCommandAvailable.mockReset();
@@ -375,6 +381,59 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
},
}));
vi.mock("./providers/trae-acp-agent.js", () => ({
TraeACPAgentClient: class TraeACPAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "acp";
readonly runtimeSettings?: unknown;
constructor(options: {
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}) {
this.runtimeSettings = {
command: {
mode: "replace",
argv: options.command,
},
env: options.env,
};
mockState.constructorArgs.trae.push({
command: options.command,
env: options.env,
providerParams: options.providerParams,
});
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
return true;
}
},
}));
import {
AGENT_PROVIDER_DEFINITIONS,
buildProviderRegistry,
@@ -638,6 +697,33 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
expect(mockState.constructorArgs.genericAcp).toEqual([]);
});
test("traecli provider extending acp uses TraeACPAgentClient", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
traecli: {
extends: "acp",
label: "TRAE CLI",
command: ["traecli", "acp", "serve"],
},
},
});
expect(registry.traecli.createClient(logger).provider).toBe("traecli");
expect(mockState.constructorArgs.trae).toEqual([
{
command: ["traecli", "acp", "serve"],
env: undefined,
providerParams: undefined,
},
{
command: ["traecli", "acp", "serve"],
env: undefined,
providerParams: undefined,
},
]);
expect(mockState.constructorArgs.genericAcp).toEqual([]);
});
test('extends: "acp" without command throws', () => {
expect(() =>
buildProviderRegistry(logger, {
@@ -36,6 +36,7 @@ import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
import {
@@ -660,6 +661,9 @@ function addDerivedProviders(
if (providerId === "kiro") {
return new KiroACPAgentClient(acpOptions);
}
if (providerId === "traecli") {
return new TraeACPAgentClient(acpOptions);
}
return new GenericACPAgentClient(acpOptions);
},
});
@@ -46,6 +46,7 @@ import { transformPiModels } from "./pi/agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js";
import { asInternals } from "../../test-utils/class-mocks.js";
import * as spawnUtils from "../../../utils/spawn.js";
@@ -540,7 +541,10 @@ describe("ACPAgentSession terminal tools", () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const shell = spawnUtils.platformShell();
const shell = buildStringCommandShellInvocation({
command: "git -C /repo status --short",
windowsShell: "cmd",
});
await session.createTerminal({
sessionId: "session-1",
@@ -549,12 +553,50 @@ describe("ACPAgentSession terminal tools", () => {
});
expect(spawn).toHaveBeenCalledWith(
shell.command,
[...shell.flag, "git -C /repo status --short"],
expect.objectContaining({ cwd: "/repo" }),
shell.shell,
shell.args,
expect.objectContaining({
cwd: "/repo",
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
shell: false,
}),
);
});
test("preserves cmd semantics for single-string terminal commands on Windows", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
try {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
await session.createTerminal({
sessionId: "session-1",
command: "echo %TEMP% && echo ok",
cwd: "C:\\repo",
});
expect(spawn).toHaveBeenCalledWith(
"cmd.exe",
["/c", "echo %TEMP% && echo ok"],
expect.objectContaining({
cwd: "C:\\repo",
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
shell: false,
}),
);
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
}
});
test("preserves explicit terminal argv", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
@@ -102,7 +102,11 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
} from "../../../utils/string-command-shell.js";
import { spawnProcess } from "../../../utils/spawn.js";
import {
type DiagnosticEntry,
toDiagnosticErrorMessage,
@@ -181,7 +185,7 @@ function toACPRequestError(error: unknown): Error {
function resolveTerminalCommand(
command: string,
args?: string[],
): { command: string; args: string[] } {
): { command: string; args: string[]; shell?: boolean } {
if (args && args.length > 0) {
return { command, args };
}
@@ -190,8 +194,8 @@ function resolveTerminalCommand(
return { command, args: [] };
}
const shell = platformShell();
return { command: shell.command, args: [...shell.flag, command] };
const shell = buildStringCommandShellInvocation({ command, windowsShell: "cmd" });
return { command: shell.shell, args: shell.args, shell: false };
}
function formatDurationMs(startedAt: number): string {
@@ -2159,12 +2163,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
);
const terminalCommand = resolveTerminalCommand(params.command, params.args);
const commandEnvOverlays =
terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env];
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
cwd: params.cwd ?? this.config.cwd,
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [env],
overlays: commandEnvOverlays,
}),
shell: terminalCommand.shell,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -0,0 +1,30 @@
import type { Logger } from "pino";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
interface TraeACPAgentClientOptions {
logger: Logger;
command: [string, ...string[]];
env?: Record<string, string>;
providerId?: string;
label?: string;
providerParams?: unknown;
}
const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
export class TraeACPAgentClient extends GenericACPAgentClient {
constructor(options: TraeACPAgentClientOptions) {
super({
logger: options.logger,
command: options.command,
env: options.env,
providerId: options.providerId,
label: options.label,
providerParams: options.providerParams,
// traecli publishes slash commands and skills asynchronously via available_commands_update.
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
});
}
}
@@ -110,6 +110,7 @@ export interface PaseoToolHostDependencies {
cwd: string,
firstAgentContext?: FirstAgentContext,
) => Promise<string>;
browserToolsEnabled?: boolean;
browserToolsBroker?: BrowserToolsBroker | null;
paseoHome?: string;
worktreesRoot?: string;
@@ -1027,7 +1028,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
return toCatalog();
}
if (options.browserToolsBroker) {
if (options.browserToolsEnabled && options.browserToolsBroker) {
registerBrowserTools({
registerTool,
broker: options.browserToolsBroker,
+2 -3
View File
@@ -484,9 +484,7 @@ export async function createPaseoDaemon(
logger,
);
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({
policy: browserToolsPolicy,
});
const browserToolsBroker = new BrowserToolsBroker({});
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
@@ -1018,6 +1016,7 @@ export async function createPaseoDaemon(
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
createPaseoWorktree: createPaseoWorktreeForTools,
browserToolsEnabled: browserToolsPolicy.isEnabled(),
browserToolsBroker,
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
@@ -7,7 +7,6 @@ import type {
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { BrowserToolsBroker, type BrowserHostClient } from "./broker.js";
import { StaticBrowserToolsPolicy } from "./policy.js";
const BROWSER_ID = "11111111-1111-4111-8111-111111111111";
const SECOND_BROWSER_ID = "22222222-2222-4222-8222-222222222222";
@@ -65,9 +64,8 @@ class FailingBrowserHostClient implements BrowserHostClient {
}
}
function createBroker(options: { enabled: boolean; timeoutMs?: number }): BrowserToolsBroker {
function createBroker(options: { timeoutMs?: number } = {}): BrowserToolsBroker {
return new BrowserToolsBroker({
policy: new StaticBrowserToolsPolicy(options.enabled),
defaultTimeoutMs: options.timeoutMs ?? 100,
createRequestId: () => "req-1",
});
@@ -82,22 +80,8 @@ describe("BrowserToolsBroker", () => {
vi.useRealTimers();
});
test("disabled returns browser_disabled", async () => {
const broker = createBroker({ enabled: false });
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
retryable: false,
},
});
});
test("no connected browser host returns a retryable browser_no_host error", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
requestId: "req-1",
@@ -111,7 +95,7 @@ describe("BrowserToolsBroker", () => {
});
test("invalid browser requests return structured failures without contacting a host", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
@@ -136,7 +120,7 @@ describe("BrowserToolsBroker", () => {
});
test("capable browser host receives request and returns response", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
@@ -194,7 +178,7 @@ describe("BrowserToolsBroker", () => {
});
test("single browser host receives snapshot requests", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
@@ -246,7 +230,7 @@ describe("BrowserToolsBroker", () => {
});
test("new tabs target the most recently registered host and tab commands stay with that host", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const firstHost = new FakeBrowserHostClient("host-1");
const recentHost = new FakeBrowserHostClient("host-2");
broker.registerClient(firstHost);
@@ -319,7 +303,7 @@ describe("BrowserToolsBroker", () => {
});
test("list tabs aggregates all hosts and seeds browser id affinity", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const firstHost = new FakeBrowserHostClient("host-1");
const secondHost = new FakeBrowserHostClient("host-2");
broker.registerClient(firstHost);
@@ -489,7 +473,7 @@ describe("BrowserToolsBroker", () => {
: never
: never;
}>)("routes $name to the host that owns the browser id", async ({ command, result }) => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const other = new FakeBrowserHostClient("host-1");
const owner = new FakeBrowserHostClient("host-2");
broker.registerClient(other);
@@ -531,7 +515,7 @@ describe("BrowserToolsBroker", () => {
});
test("successful close_tab clears browser id host affinity", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const other = new FakeBrowserHostClient("host-1");
const owner = new FakeBrowserHostClient("host-2");
broker.registerClient(other);
@@ -586,7 +570,7 @@ describe("BrowserToolsBroker", () => {
});
test("failed list tabs aggregation does not seed browser id affinity", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const firstHost = new FakeBrowserHostClient("host-1");
const secondHost = new FakeBrowserHostClient("host-2");
broker.registerClient(firstHost);
@@ -651,7 +635,7 @@ describe("BrowserToolsBroker", () => {
});
test("unsupported commands are rejected before sending to the routed host", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1", {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
@@ -700,7 +684,7 @@ describe("BrowserToolsBroker", () => {
});
test("unregistering a host strands its browser ids instead of routing them to another host", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const owner = new FakeBrowserHostClient("host-1");
const unregisterOwner = broker.registerClient(owner);
@@ -742,7 +726,7 @@ describe("BrowserToolsBroker", () => {
});
test("reconnecting the same host reclaims its stranded browser ids", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const owner = new FakeBrowserHostClient("host-1");
const unregisterOwner = broker.registerClient(owner);
@@ -803,7 +787,7 @@ describe("BrowserToolsBroker", () => {
test("timeout resolves browser_timeout and clears pending state", async () => {
vi.useFakeTimers();
const broker = createBroker({ enabled: true, timeoutMs: 50 });
const broker = createBroker({ timeoutMs: 50 });
broker.registerClient(new FakeBrowserHostClient("host-1"));
const resultPromise = broker.execute({ command: snapshotCommand() });
@@ -824,7 +808,7 @@ describe("BrowserToolsBroker", () => {
});
test("disconnect resolves retryable failure and clears pending request", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
const unregister = broker.registerClient(client);
@@ -846,7 +830,7 @@ describe("BrowserToolsBroker", () => {
});
test("replacing a host registration resolves pending requests from the old registration", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const oldHost = new FakeBrowserHostClient("host-1");
const newHost = new FakeBrowserHostClient("host-1");
broker.registerClient(oldHost);
@@ -884,7 +868,7 @@ describe("BrowserToolsBroker", () => {
});
test("browser host send failure resolves structured failure and clears pending request", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
broker.registerClient(new FailingBrowserHostClient());
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
@@ -900,7 +884,7 @@ describe("BrowserToolsBroker", () => {
});
test("explicit browser failure response propagates typed error", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
@@ -929,7 +913,7 @@ describe("BrowserToolsBroker", () => {
});
test("invalid browser response resolves a structured failure and clears pending state", async () => {
const broker = createBroker({ enabled: true });
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
@@ -8,7 +8,6 @@ import {
type BrowserAutomationExecuteResponse,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { browserToolsFailure, type BrowserToolsResponsePayload } from "./errors.js";
import type { BrowserToolsPolicy } from "./policy.js";
export interface BrowserHostClient {
id: string;
@@ -40,7 +39,6 @@ interface RegisteredBrowserHost {
}
export interface BrowserToolsBrokerOptions {
policy: BrowserToolsPolicy;
defaultTimeoutMs?: number;
createRequestId?: () => string;
}
@@ -48,7 +46,6 @@ export interface BrowserToolsBrokerOptions {
const DEFAULT_BROWSER_TOOLS_TIMEOUT_MS = 15_000;
export class BrowserToolsBroker {
private readonly policy: BrowserToolsPolicy;
private readonly defaultTimeoutMs: number;
private readonly createRequestId: () => string;
private readonly clients = new Map<string, RegisteredBrowserHost>();
@@ -58,7 +55,6 @@ export class BrowserToolsBroker {
private registrationSequence = 0;
public constructor(options: BrowserToolsBrokerOptions) {
this.policy = options.policy;
this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_BROWSER_TOOLS_TIMEOUT_MS;
this.createRequestId = options.createRequestId ?? (() => `browser_${randomUUID()}`);
}
@@ -117,14 +113,6 @@ export class BrowserToolsBroker {
public async execute(input: BrowserToolsExecuteInput): Promise<BrowserToolsResponsePayload> {
const requestId = input.requestId ?? this.createRequestId();
if (!this.policy.isEnabled()) {
return browserToolsFailure({
requestId,
code: "browser_disabled",
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
});
}
const request = BrowserAutomationExecuteRequestSchema.safeParse({
type: "browser.automation.execute.request",
requestId,
@@ -4,14 +4,6 @@ export interface BrowserToolsPolicy {
isEnabled(): boolean;
}
export class StaticBrowserToolsPolicy implements BrowserToolsPolicy {
public constructor(private readonly enabled: boolean) {}
public isEnabled(): boolean {
return this.enabled;
}
}
export class DaemonConfigBrowserToolsPolicy implements BrowserToolsPolicy {
public constructor(private readonly configStore: Pick<DaemonConfigStore, "get">) {}
@@ -472,27 +472,6 @@ const routedToolCases = [
}>;
const brokerErrorCases = [
{
name: "disabled browser tools",
toolName: "browser_list_tabs",
input: {},
payload: {
requestId: "req-disabled",
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
retryable: false,
},
},
content: [
{
type: "text",
text: "Browser tools are disabled. Enable browser tools on the host, then try again.",
},
],
context: { agentId: "agent-1", cwd: "/repo", workspaceId: "wks_workspace_a" },
},
{
name: "typed timeout errors",
toolName: "browser_snapshot",
+12 -3
View File
@@ -14,7 +14,11 @@ import type {
AgentTimelineItem,
AgentProvider,
} from "./agent/agent-sdk-types.js";
import { execCommand, platformShell } from "../utils/spawn.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
} from "../utils/string-command-shell.js";
import { execCommand } from "../utils/spawn.js";
import type {
ProviderSnapshotManager,
ResolvedProviderCreateConfig,
@@ -266,10 +270,15 @@ async function runVerifyCheck(options: {
}): Promise<LoopVerifyCheckResult> {
const startedAt = nowIso();
try {
const shell = platformShell();
const result = await execCommand(shell.command, [...shell.flag, options.command], {
const shell = buildStringCommandShellInvocation({
command: options.command,
windowsShell: "cmd",
});
const result = await execCommand(shell.shell, shell.args, {
cwd: options.cwd,
envOverlay: createStringCommandShellEnvOverlay(),
maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
shell: false,
});
return {
command: options.command,
@@ -6,6 +6,29 @@ import { describe, expect, test } from "vitest";
import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js";
describe("pid-lock ownership", () => {
test("records executable identity without writing desktop management intent", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-exec-"));
const previousDesktopManaged = process.env.PASEO_DESKTOP_MANAGED;
try {
process.env.PASEO_DESKTOP_MANAGED = "1";
await acquirePidLock(paseoHome, null, { ownerPid: process.pid });
const lock = await getPidLockInfo(paseoHome);
expect(lock?.executablePath).toBe(process.execPath);
expect(lock?.desktopManaged).toBeUndefined();
} finally {
if (previousDesktopManaged === undefined) {
delete process.env.PASEO_DESKTOP_MANAGED;
} else {
process.env.PASEO_DESKTOP_MANAGED = previousDesktopManaged;
}
await releasePidLock(paseoHome, { ownerPid: process.pid });
await rm(paseoHome, { recursive: true, force: true });
}
});
test("writes and releases lock for explicit owner pid", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-"));
const ownerPid = process.pid + 10_000;
@@ -22,6 +45,7 @@ describe("pid-lock ownership", () => {
const lock = await getPidLockInfo(paseoHome);
expect(lock?.pid).toBe(ownerPid);
expect(lock?.listen).toBeNull();
expect(lock?.executablePath).toBe(process.execPath);
await (
updatePidLock as unknown as (
+3 -1
View File
@@ -10,6 +10,8 @@ export const pidLockInfoSchema = z.object({
hostname: z.string(),
uid: z.number(),
listen: z.string().nullable(),
executablePath: z.string().optional(),
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
desktopManaged: z.boolean().optional(),
});
@@ -99,7 +101,7 @@ export async function acquirePidLock(
hostname: hostname(),
uid: process.getuid?.() ?? 0,
listen,
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
executablePath: process.execPath,
};
let fd;
@@ -14,7 +14,6 @@ import { afterEach, describe, expect, it } from "vitest";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import { BrowserToolsBroker } from "./browser-tools/broker.js";
import { StaticBrowserToolsPolicy } from "./browser-tools/policy.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
@@ -256,7 +255,6 @@ async function startBrowserToolsDaemonHarness(): Promise<BrowserToolsDaemonHarne
function createBroker(): BrowserToolsBroker {
return new BrowserToolsBroker({
policy: new StaticBrowserToolsPolicy(true),
defaultTimeoutMs: 500,
createRequestId: createRequestIdSequence(),
});
-16
View File
@@ -113,19 +113,3 @@ export async function execCommand(
windowsHide: true,
}) as Promise<ExecCommandResult>;
}
export function platformShell(): { command: string; flag: string[] } {
if (process.platform === "win32") {
return { command: "cmd.exe", flag: ["/c"] };
}
return { command: "/bin/sh", flag: ["-lc"] };
}
export function platformBash(): { command: string; flag: string[] } {
if (process.platform === "win32") {
return { command: "cmd.exe", flag: ["/c"] };
}
return { command: "/bin/bash", flag: ["-lc"] };
}
@@ -1,21 +1,76 @@
import { describe, expect, it } from "vitest";
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnv,
} from "./string-command-shell.js";
function hasBashOnPath(): boolean {
const result = spawnSync("bash", ["-c", "true"], { stdio: "ignore" });
return !result.error && result.status === 0;
}
describe("buildStringCommandShellInvocation", () => {
it("uses bash login-command semantics on unix platforms", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs) {
rmSync(tempDir, { recursive: true, force: true });
}
tempDirs.length = 0;
});
it("uses bash script semantics on unix platforms", () => {
expect(
buildStringCommandShellInvocation({
command: 'echo "hello"',
platform: "darwin",
}),
).toEqual({
shell: "/bin/bash",
args: ["-lc", 'echo "hello"'],
shell: "bash",
args: ["-c", 'echo "hello"'],
});
});
it("uses powershell command semantics on windows", () => {
it.skipIf(process.platform === "win32" || !hasBashOnPath())(
"preserves the supplied PATH when login profiles rewrite it",
() => {
const home = mkdtempSync(join(tmpdir(), "paseo-shell-home-"));
tempDirs.push(home);
const binDir = join(home, "bin");
mkdirSync(binDir);
const shimPath = join(binDir, "paseo-shim");
writeFileSync(shimPath, "#!/bin/sh\nprintf 'shim:%s\\n' \"$1\"\n");
chmodSync(shimPath, 0o755);
writeFileSync(join(home, ".bash_profile"), "export PATH=/usr/bin:/bin\n");
const bashEnvPath = join(home, "bash-env");
writeFileSync(bashEnvPath, "export PATH=/usr/bin:/bin\n");
const invocation = buildStringCommandShellInvocation({
command: "command -v paseo-shim >/dev/null && paseo-shim ok",
});
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: home,
PATH: `${binDir}${delimiter}${process.env.PATH ?? "/usr/bin:/bin"}`,
BASH_ENV: bashEnvPath,
};
const stdout = execFileSync(invocation.shell, invocation.args, {
encoding: "utf8",
env: createStringCommandShellEnv(env),
});
expect(stdout.trim()).toBe("shim:ok");
},
);
it("uses powershell command semantics on windows by default", () => {
expect(
buildStringCommandShellInvocation({
command: "Write-Output 'hello'",
@@ -33,4 +88,17 @@ describe("buildStringCommandShellInvocation", () => {
],
});
});
it("can preserve cmd command semantics on windows", () => {
expect(
buildStringCommandShellInvocation({
command: "echo %TEMP% && echo ok",
platform: "win32",
windowsShell: "cmd",
}),
).toEqual({
shell: "cmd.exe",
args: ["/c", "echo %TEMP% && echo ok"],
});
});
});
@@ -1,6 +1,7 @@
export interface BuildStringCommandShellInvocationOptions {
command: string;
platform?: NodeJS.Platform;
windowsShell?: "powershell" | "cmd";
}
export interface StringCommandShellInvocation {
@@ -8,12 +9,31 @@ export interface StringCommandShellInvocation {
args: string[];
}
export function createStringCommandShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized = { ...env };
delete sanitized.BASH_ENV;
return sanitized;
}
export function createStringCommandShellEnvOverlay(): Record<string, string | undefined> {
return { BASH_ENV: undefined };
}
export function buildStringCommandShellInvocation(
options: BuildStringCommandShellInvocationOptions,
): StringCommandShellInvocation {
const platform = options.platform ?? process.platform;
// Project-authored command strings use a stable script shell. The caller supplies
// the environment; shell startup files should not rewrite it behind our back.
if (platform === "win32") {
if (options.windowsShell === "cmd") {
return {
shell: "cmd.exe",
args: ["/c", options.command],
};
}
return {
shell: "powershell",
args: [
@@ -28,7 +48,7 @@ export function buildStringCommandShellInvocation(
}
return {
shell: "/bin/bash",
args: ["-lc", options.command],
shell: "bash",
args: ["-c", options.command],
};
}
@@ -1,9 +1,12 @@
import { type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const execFileMock = vi.hoisted(() => vi.fn());
const spawnProcessMock = vi.hoisted(() => vi.fn());
vi.mock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
@@ -13,11 +16,34 @@ vi.mock("child_process", async () => {
};
});
vi.mock("./spawn.js", async () => {
const actual = await vi.importActual<typeof import("./spawn.js")>("./spawn.js");
return {
...actual,
spawnProcess: spawnProcessMock,
};
});
function emitSuccessfulClose(child: ChildProcess): void {
child.emit("close", 0);
}
function createSpawnChildStub(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
Object.assign(child, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
queueMicrotask(() => emitSuccessfulClose(child));
return child;
}
describe("worktree shell selection", () => {
const originalPlatform = process.platform;
beforeEach(() => {
execFileMock.mockReset();
spawnProcessMock.mockReset();
execFileMock.mockImplementation(
(
_file: string,
@@ -29,6 +55,7 @@ describe("worktree shell selection", () => {
return {};
},
);
spawnProcessMock.mockImplementation(createSpawnChildStub);
});
afterEach(() => {
@@ -46,6 +73,8 @@ describe("worktree shell selection", () => {
});
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
const originalBashEnv = process.env.BASH_ENV;
process.env.BASH_ENV = "should-not-leak";
try {
mkdirSync(join(worktreePath, ".git"), { recursive: true });
writeFileSync(
@@ -79,7 +108,74 @@ describe("worktree shell selection", () => {
expect.objectContaining({ cwd: worktreePath }),
expect.any(Function),
);
const execOptions = execFileMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
expect(execOptions.env?.BASH_ENV).toBeUndefined();
} finally {
if (originalBashEnv === undefined) {
delete process.env.BASH_ENV;
} else {
process.env.BASH_ENV = originalBashEnv;
}
rmSync(worktreePath, { recursive: true, force: true });
}
});
it("routes streamed setup command execution through powershell on win32", async () => {
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
const originalBashEnv = process.env.BASH_ENV;
process.env.BASH_ENV = "should-not-leak";
try {
writeFileSync(
join(worktreePath, "paseo.json"),
JSON.stringify({
worktree: {
setup: ["Write-Output 'setup'"],
},
}),
"utf8",
);
const { runWorktreeSetupCommands } = await import("./worktree.js");
await runWorktreeSetupCommands({
worktreePath,
branchName: "main",
cleanupOnFailure: false,
runtimeEnv: {
PASEO_SOURCE_CHECKOUT_PATH: worktreePath,
PASEO_ROOT_PATH: worktreePath,
PASEO_WORKTREE_PATH: worktreePath,
PASEO_BRANCH_NAME: "main",
PASEO_WORKTREE_PORT: "12345",
},
onEvent: () => {},
});
expect(spawnProcessMock).toHaveBeenCalledTimes(1);
expect(spawnProcessMock).toHaveBeenCalledWith(
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
"Write-Output 'setup'",
],
expect.objectContaining({ cwd: worktreePath, shell: false }),
);
const spawnOptions = spawnProcessMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
expect(spawnOptions.env?.BASH_ENV).toBeUndefined();
} finally {
if (originalBashEnv === undefined) {
delete process.env.BASH_ENV;
} else {
process.env.BASH_ENV = originalBashEnv;
}
rmSync(worktreePath, { recursive: true, force: true });
}
});
@@ -33,8 +33,9 @@ import {
realpathSync,
writeFileSync,
readFileSync,
chmodSync,
} from "fs";
import { dirname, join } from "path";
import { delimiter, dirname, join } from "path";
import { tmpdir } from "os";
import net from "node:net";
@@ -637,6 +638,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
);
});
it("runs setup commands with the daemon PATH instead of login profile PATH", async () => {
const home = join(tempDir, "host-home");
const binDir = join(tempDir, "daemon-bin");
mkdirSync(home);
mkdirSync(binDir);
const shimPath = join(binDir, "paseo-shim");
writeFileSync(shimPath, "#!/bin/sh\nprintf 'shim:%s\\n' \"$1\"\n");
chmodSync(shimPath, 0o755);
writeFileSync(join(home, ".bash_profile"), "export PATH=/usr/bin:/bin\n");
const bashEnvPath = join(home, "bash-env");
writeFileSync(bashEnvPath, "export PATH=/usr/bin:/bin\n");
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: "command -v paseo-shim >/dev/null && paseo-shim ok > setup-path.log",
},
}),
);
const originalHome = process.env.HOME;
const originalPath = process.env.PATH;
const originalBashEnv = process.env.BASH_ENV;
process.env.HOME = home;
process.env.PATH = `${binDir}${delimiter}${originalPath ?? "/usr/bin:/bin"}`;
process.env.BASH_ENV = bashEnvPath;
try {
await runWorktreeSetupCommands({
worktreePath: repoDir,
branchName: "main",
cleanupOnFailure: false,
runtimeEnv: {
PASEO_SOURCE_CHECKOUT_PATH: repoDir,
PASEO_ROOT_PATH: repoDir,
PASEO_WORKTREE_PATH: repoDir,
PASEO_BRANCH_NAME: "main",
PASEO_WORKTREE_PORT: "12345",
},
});
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalBashEnv === undefined) {
delete process.env.BASH_ENV;
} else {
process.env.BASH_ENV = originalBashEnv;
}
}
expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok");
});
it("treats blank lifecycle strings as empty", () => {
writeFileSync(
join(repoDir, "paseo.json"),
+19 -13
View File
@@ -6,7 +6,10 @@ import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import stripAnsi from "strip-ansi";
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnv,
} from "./string-command-shell.js";
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
export {
PaseoConfigRawSchema,
@@ -489,6 +492,7 @@ async function execSetupCommandStreamed(options: {
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -607,7 +611,7 @@ export async function runWorktreeSetupCommands(options: {
branchName: options.branchName,
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
}));
const setupEnv = createExternalProcessEnv(process.env, runtimeEnv);
const setupEnv = createStringCommandShellEnv(createExternalProcessEnv(process.env, runtimeEnv));
const results: WorktreeSetupCommandResult[] = [];
for (const [index, cmd] of setupCommands.entries()) {
@@ -715,17 +719,19 @@ export async function runWorktreeTeardownCommands(options: {
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
const teardownEnv: NodeJS.ProcessEnv = createExternalProcessEnv(process.env, {
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
// from the source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName,
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
});
const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv(
createExternalProcessEnv(process.env, {
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
// from the source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName,
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
}),
);
const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of teardownCommands) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.104-beta.3",
"version": "0.1.104-beta.4",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -56,7 +56,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in
- [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/users/overview), Alibaba's Qwen coding assistant.
- [siGit Code](https://github.com/getsigit/sigit), local-first coding agent with optional on-device LLM.
- [Stakpak](https://stakpak.dev/), Rust-based DevOps agent.
- [TRAE CLI](https://docs.trae.cn/cli), ByteDance's official TRAE coding agent.
- [TRAE CLI](https://docs.trae.cn/cli_get-started-with-trae-cli), ByteDance's official TRAE coding agent.
- [VT Code](https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md), open-source multi-provider coding agent.
The in-app catalog is the canonical, version-pinned source. Anything not listed here can still be added manually, see [Custom providers](/docs/custom-providers).