Compare commits

...
Author SHA1 Message Date
Mohamed Boudra c56638ea8c chore(release): cut 0.7.0 2026-08-31 19:25:32 +02:00
Mohamed Boudra 185ab3f300 docs: prepare v0.7.0 changelog 2026-08-31 19:20:58 +02:00
Matt Cowger 1694828275 fix(opencode): show child session prompts (#4055)
Show OpenCode child-session user prompts even when the text part has no completion timestamp.
2026-08-31 18:35:28 +02:00
74a377ff6a fix(server): keep daemon alive when JSONL RPC stdin hits EPIPE (#4048)
* fix(server): keep daemon alive when JSONL RPC stdin hits EPIPE

A dead Claude/OMP child currently turns stdin.write() EPIPE into
uncaughtException and kills the daemon. Catch the write, listen for
stdin errors, and close that transport instead.

Closes #4047

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(server): reproduce JSONL RPC stdin EPIPE

* test(server): gate native EPIPE fixture on POSIX

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-31 15:35:34 +02:00
Mohamed Boudra 63923fdd07 Keep Android timeline still when tapping text (#4090)
* fix(android): keep timeline still when tapping text

Selectable Android text takes touch focus. React Native then scrolls focused children using untransformed coordinates, which moves inverted timelines even though the user did not scroll.\n\nSkip that auto-scroll only for touch-focused, selectable, non-editable text in inverted vertical scroll views, preserving normal focus navigation and editable inputs.

* test(e2e): isolate PR pane fixtures from bots
2026-08-31 12:10:40 +02:00
Mohamed Boudra 11e76f6655 fix(server): resume agents cleanly after failed turns
Track each foreground run start as pending, started, or failed so a previous turn error cannot reject a newly accepted prompt. Includes a regression for resuming after a failed turn.
2026-08-31 12:10:34 +02:00
Mohamed Boudra 4a3e6dea63 Increase mobile content text to 16px (#4111)
Keep mobile interface chrome at 15px while defaulting readable content to 16px. Migrate existing mobile 15px content preferences once while preserving later explicit choices.
2026-08-31 11:58:42 +02:00
paseo-ai[bot] f35d860725 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-31 08:17:46 +00:00
Mohamed Boudra 95f4eb7577 chore(release): cut 0.7.0-beta.3 2026-08-31 09:52:59 +02:00
Mohamed Boudra fcd81f591e chore(release): carry forward 0.7.0-beta.2 version 2026-08-31 09:46:56 +02:00
Mohamed Boudra b0fe4efc8c docs: add v0.7.0-beta.3 changelog 2026-08-31 09:45:26 +02:00
Mohamed Boudra f2f93087ed docs(website): refresh product comparisons
Reframe the pages around verified product differences while emphasizing local ownership, privacy, extensibility, and deployment flexibility.
2026-08-30 23:48:54 +02:00
Mohamed Boudra 73a5e6c63e feat(website): expand homepage product story 2026-08-30 23:48:54 +02:00
Mohamed Boudra fa53ba8f93 feat(website): refine responsive product mockups
Keep desktop and mobile scenes visually consistent while improving phone depth, diff realism, alignment, and interaction polish.
2026-08-30 23:48:54 +02:00
Mohamed Boudra 375e9b3e53 feat(website): rebuild homepage product mockups
Replace static captures with live, responsive scenes that demonstrate building, reviewing, shipping, and extending work across desktop and mobile.
2026-08-30 23:48:54 +02:00
Mohamed Boudra adf80a9fe0 Keep Hub daemon access explicit (#4088)
* feat(hub): make daemon authority explicit

Connecting a daemon establishes presence without granting execution. Hub authority now uses semantic session permissions, with workflow execution granted explicitly and inspectable through the normal session protocol.

Attention frames remain part of the existing agent and terminal subscriptions, keeping session output solicited.

* test(server): match sentence-case tool labels
2026-08-30 22:46:24 +02:00
104 changed files with 5805 additions and 1583 deletions
+35 -1
View File
@@ -1,30 +1,64 @@
# Changelog
## 0.7.0-beta.1 - 2026-08-27
## 0.7.0 - 2026-08-31
### Changed
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111))
### Added
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066))
- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064))
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet))
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997))
### Improved
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009))
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025))
### Fixed
- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048))
- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044))
- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051))
- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090))
- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067))
- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069))
- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065))
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
- Fixed the model selector crashing in iPad desktop layouts ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim))
- Fixed OpenCode child-session prompts missing from provider subagent timelines ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger))
- Fixed archived agents disappearing when opened from History ([#4033](https://github.com/getpaseo/paseo/pull/4033))
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
- Fixed Grok unified-billing accounts showing zero credits instead of weekly usage ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude))
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
- Fixed slow Pi and OMP startup requests timing out after 30 seconds ([#4008](https://github.com/getpaseo/paseo/pull/4008))
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005))
- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068))
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
+1 -1
View File
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
Weight has three tiers, applied by role:
+10 -6
View File
@@ -17,17 +17,21 @@ The daemon persists a relationship ID and private connection credential before e
relationship is independent of its current transport, so a future transport can replace the direct
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it
during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
manage their own relationship or permissions.
## Session grants and execution ownership
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
connection receives `hub.execute`. The current enrollment and persistence format still carries
`hub.execution.*`; one compatibility boundary translates it before the session is created. A denied
request returns the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
connection receives its persisted permissions. Legacy relationship files carrying
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
+1 -1
View File
@@ -56,4 +56,4 @@ The Hub authenticates as a service principal. Its locally selected grants decide
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
The current Hub enrollment protocol still exchanges `hub.execution.*`. The daemon translates that legacy value to `hub.execute` once at the connection boundary. Remove the translation when Hub enrollment exchanges semantic permissions.
Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority.
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
sha256-YiXz9rVJxzhtKdvYsg5UhVXTaetvH0sdJ/8dyfvSGQQ=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36298,7 +36298,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37363,12 +37363,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/server": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/server": "0.7.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37615,10 +37615,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/relay": "0.7.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37629,7 +37629,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37872,7 +37872,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38768,7 +38768,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38797,13 +38797,13 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38812,15 +38812,15 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38833,7 +38833,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39051,17 +39051,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/highlight": "0.7.0-beta.1",
"@getpaseo/plugin": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0",
"@getpaseo/highlight": "0.7.0",
"@getpaseo/plugin": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/relay": "0.7.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39985,7 +39985,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+10 -15
View File
@@ -3,8 +3,8 @@ import {
openPrPane,
expectPrPaneTitle,
expectPrPaneState,
expectPrPaneCheckSummary,
expectPrPaneActivityCount,
expectPrPaneChecks,
expectPrPaneActivity,
} from "../support/helpers/pr-pane";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
@@ -50,7 +50,6 @@ test.describe("PR pane", () => {
],
},
{ title: "PR with reviews", state: "open", commentCount: 3 },
{ title: "PR with no checks", state: "open" },
],
});
@@ -102,25 +101,21 @@ test.describe("PR pane", () => {
await expectPrPaneTitle(page, "Work in progress");
});
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
await expectPrPaneChecks(page, {
success: ["build-1", "build-2"],
failure: ["deploy"],
pending: ["security"],
});
});
test("renders activity rows with correct count", async ({ page }) => {
test("renders every seeded activity comment", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
await openPrPane(page);
await expectPrPaneActivityCount(page, 3);
});
test("renders gracefully with zero checks", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
await expectPrPaneTitle(page, "PR with no checks");
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
});
});
@@ -43,7 +43,6 @@ const cases: ProviderSubagentCase[] = [
sentinel: "OPENCODE_CHILD_SENTINEL",
expectedName: "Verify OpenCode descriptor",
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
expectsUserMessage: false,
providerConfig: { model: "openai/gpt-5.4" },
prompt:
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
+24 -17
View File
@@ -1,5 +1,4 @@
import { expect, type Page } from "@playwright/test";
import type { CheckPresentationCounts } from "@/git/check-presentation";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { openPullRequestPanel } from "./workspace-tabs";
@@ -20,23 +19,31 @@ export async function expectPrPaneState(
});
}
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
const locator = page.getByTestId(testId);
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
if (count > 0) {
await expect(locator).toContainText(String(count));
export async function expectPrPaneChecks(
page: Page,
checks: { success: string[]; failure: string[]; pending: string[] },
): Promise<void> {
const section = page.getByTestId("pr-pane-checks");
const groups = [
{ status: "failure", label: /failing checks?$/, names: checks.failure },
{ status: "pending", label: /in progress checks?$/, names: checks.pending },
{ status: "success", label: /successful checks?$/, names: checks.success },
];
for (const group of groups) {
const groupSection = section.getByTestId(`pr-pane-check-group-${group.status}`);
await expect(groupSection.getByRole("button", { name: group.label })).toBeVisible({
timeout: 15_000,
});
for (const name of group.names) {
await expect(groupSection.getByText(name, { exact: true })).toHaveCount(1);
}
}
}
export async function expectPrPaneCheckSummary(
page: Page,
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
): Promise<void> {
await assertCheckPill(page, "pr-pane-check-success", counts.success);
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
}
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
export async function expectPrPaneActivity(page: Page, bodies: string[]): Promise<void> {
const activity = page.getByTestId("pr-pane-activity-row");
for (const body of bodies) {
await expect(activity.getByText(body, { exact: true })).toBeVisible({ timeout: 15_000 });
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -148,7 +148,7 @@ function CheckGroup({
}) {
const handlePress = useCallback(() => onToggle(group.status), [group.status, onToggle]);
return (
<View>
<View testID={`pr-pane-check-group-${group.status}`}>
<Pressable onPress={handlePress} style={styles.groupHeader} accessibilityRole="button">
<Text style={styles.groupLabel}>{group.label}</Text>
{collapsed ? (
@@ -20,6 +20,11 @@ function storedSendBehavior(storage: Storage): SendBehavior | undefined {
return raw === undefined ? undefined : JSON.parse(raw).sendBehavior;
}
function storedContentFontSize(storage: Storage): number | undefined {
const raw = storage.entries.get(APP_SETTINGS_KEY);
return raw === undefined ? undefined : JSON.parse(raw).contentFontSize;
}
/** An in-memory storage whose write to `failingKey` always throws, as a full disk would. */
function createFailingWriteStorage(failingKey: string): Storage {
const storage = createInMemoryKeyValueStorage();
@@ -81,6 +86,47 @@ describe("migrateAppSettings", () => {
expect(appliedIds(storage)).toEqual(["some-later-migration", "steer-default"]);
});
it("migrates every mobile 15px content preference to 16px", async () => {
const storage = createInMemoryKeyValueStorage();
const settings = { ...settingsWith("steer"), contentFontSize: 15 };
const result = await migrateAppSettings(settings, storage, undefined, { native: true });
expect(result.contentFontSize).toBe(16);
expect(storedContentFontSize(storage)).toBe(16);
expect(appliedIds(storage)).toEqual(["steer-default", "mobile-content-16"]);
});
it("leaves a 15px web content preference unchanged", async () => {
const storage = createInMemoryKeyValueStorage();
const settings = { ...settingsWith("steer"), contentFontSize: 15 };
const result = await migrateAppSettings(settings, storage, undefined, { native: false });
expect(result.contentFontSize).toBe(15);
expect(storedContentFontSize(storage)).toBeUndefined();
expect(appliedIds(storage)).toEqual(["steer-default"]);
});
it("lets a mobile user choose 15px after the default migration ran", async () => {
const storage = createInMemoryKeyValueStorage();
await migrateAppSettings(
{ ...settingsWith("steer"), contentFontSize: 15 },
storage,
undefined,
{ native: true },
);
const result = await migrateAppSettings(
{ ...settingsWith("steer"), contentFontSize: 15 },
storage,
undefined,
{ native: true },
);
expect(result.contentFontSize).toBe(15);
});
it("stays unmarked when the settings write fails, so a later launch retries", async () => {
const storage = createFailingWriteStorage(APP_SETTINGS_KEY);
@@ -13,6 +13,9 @@ const AppliedMigrationsSchema = z.strictObject({ applied: z.array(z.string()) })
*/
const STEER_DEFAULT_MIGRATION = "steer-default";
/** Existing mobile installs materialized the old 15px content default in storage. */
const MOBILE_CONTENT_16_MIGRATION = "mobile-content-16";
/**
* Brings stored settings up to date, returning what the caller should use. Owns both writes so
* the marker can only ever be written after the settings it describes: a failed marker write
@@ -23,6 +26,7 @@ export async function migrateAppSettings(
settings: AppSettings,
storage: KeyValueStorage,
stored?: PersistedAppSettings,
options: { native?: boolean } = {},
): Promise<AppSettings> {
const migrationMarker = await readValidatedJson(
storage,
@@ -30,12 +34,26 @@ export async function migrateAppSettings(
AppliedMigrationsSchema,
);
const applied = new Set(migrationMarker?.applied ?? []);
if (applied.has(STEER_DEFAULT_MIGRATION)) {
let addedMigration = false;
let migrated = settings;
if (!applied.has(STEER_DEFAULT_MIGRATION)) {
migrated =
migrated.sendBehavior === "interrupt" ? { ...migrated, sendBehavior: "steer" } : migrated;
applied.add(STEER_DEFAULT_MIGRATION);
addedMigration = true;
}
if (options.native && !applied.has(MOBILE_CONTENT_16_MIGRATION)) {
migrated = migrated.contentFontSize === 15 ? { ...migrated, contentFontSize: 16 } : migrated;
applied.add(MOBILE_CONTENT_16_MIGRATION);
addedMigration = true;
}
if (!addedMigration) {
return settings;
}
const migrated: AppSettings =
settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings;
if (migrated !== settings) {
const storedSidebarRowItems = stored?.sidebarRowItems ?? {};
await storage.setItem(
@@ -48,7 +66,6 @@ export async function migrateAppSettings(
);
}
applied.add(STEER_DEFAULT_MIGRATION);
await storage.setItem(SETTINGS_MIGRATIONS_KEY, JSON.stringify({ applied: [...applied] }));
return migrated;
}
@@ -659,8 +659,8 @@ describe("appearance settings", () => {
expect(defaultUiBaseFontSize(false)).toBe(14);
});
it("uses a 15px content default on mobile and web", () => {
expect(defaultContentFontSize(true)).toBe(15);
it("uses a 16px content default on mobile and a 15px default on web", () => {
expect(defaultContentFontSize(true)).toBe(16);
expect(defaultContentFontSize(false)).toBe(15);
expect(DEFAULT_CONTENT_FONT_SIZE).toBe(defaultContentFontSize(false));
});
@@ -52,7 +52,7 @@ export const DEFAULT_UI_BASE_FONT_SIZE = defaultUiBaseFontSize(isNative);
export const MIN_UI_BASE_FONT_SIZE = 10;
export const MAX_UI_BASE_FONT_SIZE = 21;
export function defaultContentFontSize(native: boolean): number {
return native ? 15 : FONT_SIZE.content;
return native ? 16 : FONT_SIZE.content;
}
export const DEFAULT_CONTENT_FONT_SIZE = defaultContentFontSize(isNative);
@@ -75,7 +75,7 @@ export interface AppSettings {
uiFontFamily: string; // "" = platform default UI stack
monoFontFamily: string; // "" = platform default mono stack
uiBaseFontSize: number; // clamped px, platform default 14 or 15
contentFontSize: number; // clamped px, default 15
contentFontSize: number; // clamped px, platform default 15 or 16
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
@@ -343,7 +343,7 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<Ap
await writeAppSettings(deps.storage, read.stored, read.settings);
}
const { needsWrite: _needsWrite, ...stored } = read.stored;
return await migrateAppSettings(read.settings, deps.storage, stored);
return await migrateAppSettings(read.settings, deps.storage, stored, { native: isNative });
} catch (error) {
console.error("[AppSettings] Failed to load settings:", error);
throw error;
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/server": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/server": "0.7.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+106 -8
View File
@@ -9,6 +9,7 @@ import { runHubLogin } from "./login.js";
import { runHubLogout } from "./logout.js";
import { runHubProjects } from "./projects.js";
import { createHubCommand } from "./index.js";
import { runHubPermissionChange, runHubPermissionsList } from "./permissions.js";
import type { HubReporter } from "./reporter.js";
const quietReporter: HubReporter = { progress() {} };
@@ -25,6 +26,7 @@ describe("Hub commands", () => {
"connect",
"status",
"disconnect",
"permissions",
"projects",
"export",
"deploy",
@@ -174,12 +176,87 @@ describe("Hub commands", () => {
assert.deepEqual(observed, [{ origin: "https://hub.test", credential: "stored-human-secret" }]);
assert.deepEqual(daemon.connections, [
{ origin: "https://hub.test", token: "one-time-enrollment-token-with-enough-length" },
{
origin: "https://hub.test",
token: "one-time-enrollment-token-with-enough-length",
permissions: [],
},
]);
assert.equal(daemon.connections[0]?.token.includes("stored-human-secret"), false);
assert.deepEqual(progress, ["Connecting this daemon to https://hub.test"]);
});
it("grants workflow execution only when explicitly requested", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new FakeDaemon("https://hub.test");
await runHubConnect(
"https://hub.test",
{ permissions: ["hub.execute"] },
{
env: {},
credentials,
hub: { issueEnrollmentToken: async () => "one-time-token" },
daemon: new FakeDaemonConnection(daemon),
reporter: quietReporter,
},
);
assert.deepEqual(daemon.connections[0]?.permissions, ["hub.execute"]);
});
it("revokes a relationship when an older daemon widens connected-only access", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new FakeDaemon("https://hub.test");
daemon.returnedPermissions = ["hub.execute"];
await assert.rejects(
runHubConnect(
"https://hub.test",
{},
{
env: {},
credentials,
hub: { issueEnrollmentToken: async () => "one-time-token" },
daemon: new FakeDaemonConnection(daemon),
reporter: quietReporter,
},
),
/did not honor the requested Hub access/u,
);
assert.equal(daemon.disconnects, 1);
});
it("lists, grants, and revokes semantic Hub permissions without a wizard", async () => {
const daemon = new FakeDaemon("https://hub.test");
daemon.returnedPermissions = [];
const connection = new FakeDaemonConnection(daemon);
const progress: string[] = [];
const dependencies = {
daemon: connection,
reporter: { progress: (message: string) => progress.push(message) },
};
const empty = await runHubPermissionsList({}, dependencies);
assert.deepEqual(empty.data, []);
await runHubPermissionChange("grant", "hub.execute", {}, dependencies);
const granted = await runHubPermissionsList({}, dependencies);
assert.deepEqual(granted.data, [
{ permission: "hub.execute", description: "Run agents for Hub automations" },
]);
await runHubPermissionChange("revoke", "hub.execute", {}, dependencies);
assert.deepEqual((await runHubPermissionsList({}, dependencies)).data, []);
assert.deepEqual(progress, [
"Granted hub.execute to https://hub.test",
"Revoked hub.execute from https://hub.test",
]);
});
it("projects reports its normalized destination before listing", async () => {
const credentials = new MemoryCredentials();
const events: string[] = [];
@@ -523,22 +600,39 @@ class FakeDaemonConnection implements HubDaemonConnection {
}
class FakeDaemon implements HubDaemonClient {
readonly connections: Array<{ origin: string; token: string }> = [];
readonly connections: Array<{
origin: string;
token: string;
permissions: readonly string[];
}> = [];
disconnects = 0;
readonly disconnectForces: boolean[] = [];
disconnectError: Error | null = null;
disconnectWarning: string | null = null;
beforeDisconnect: (() => void) | null = null;
returnedPermissions: readonly string[] | null = null;
constructor(private readonly origin: string) {}
async connectHub(origin: string, token: string) {
this.connections.push({ origin, token });
return { status: hubStatus("connected", origin) };
async connectHub(origin: string, token: string, permissions: readonly string[] = []) {
this.connections.push({ origin, token, permissions });
return {
status: hubStatus("connected", origin, this.returnedPermissions ?? permissions),
};
}
async updateHubPermissions(input: { grant?: readonly string[]; revoke?: readonly string[] }) {
const current = new Set(this.returnedPermissions ?? []);
for (const permission of input.revoke ?? []) current.delete(permission);
for (const permission of input.grant ?? []) current.add(permission);
this.returnedPermissions = [...current];
return { status: hubStatus("connected", this.origin, this.returnedPermissions) };
}
async getHubStatus() {
return { status: hubStatus("connected", this.origin) };
return {
status: hubStatus("connected", this.origin, this.returnedPermissions ?? ["hub.execute"]),
};
}
async getProvidersSnapshot() {
@@ -559,12 +653,16 @@ class FakeDaemon implements HubDaemonClient {
async close() {}
}
function hubStatus(state: string, origin: string | null): HubStatus {
function hubStatus(
state: string,
origin: string | null,
permissions: readonly string[] = ["hub.execute"],
): HubStatus {
return {
state,
daemonId: state === "connected" ? "daemon-1" : null,
hubOrigin: origin,
scopes: state === "connected" ? ["hub.execution.*"] : [],
permissions: state === "connected" ? [...permissions] : [],
connectedAt: null,
lastError: null,
};
+19 -2
View File
@@ -14,6 +14,8 @@ interface HubConnectOptions {
apiKey?: string;
host?: string;
json?: boolean;
permission?: readonly string[];
permissions?: readonly string[];
}
interface HubConnectDependencies {
@@ -38,8 +40,18 @@ export async function runHubConnect(
reportHubProgress(dependencies.reporter, options, `Connecting this daemon to ${origin}`);
const credential = resolveHubCredential({ ...resolution, origin });
const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
const permissions = options.permissions ?? options.permission ?? [];
return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
const response = await daemon.connectHub(origin, token);
const response = await daemon.connectHub(origin, token, permissions);
if (
response.status.hubOrigin !== null &&
!samePermissions(response.status.permissions, permissions)
) {
await daemon.disconnectHub(false).catch(() => undefined);
throw new Error(
"The daemon did not honor the requested Hub access. Update Paseo before connecting it.",
);
}
return hubStatusResult(response.status);
});
}
@@ -51,7 +63,8 @@ export function addHubConnectCommand(parent: Command, dependencies: HubConnectDe
.command("connect")
.description("Enroll this daemon with a Paseo Hub")
.argument("[origin]", "Paseo Hub origin")
.option("--api-key <secret>", "Organization API key"),
.option("--api-key <secret>", "Organization API key")
.option("--permission <permission...>", "Grant daemon permission during connection"),
),
).action(
withOutput(async (...args) => {
@@ -61,3 +74,7 @@ export function addHubConnectCommand(parent: Command, dependencies: HubConnectDe
}),
);
}
function samePermissions(actual: readonly string[], expected: readonly string[]): boolean {
return actual.length === expected.length && expected.every((scope) => actual.includes(scope));
}
+10 -2
View File
@@ -5,7 +5,7 @@ export interface HubStatus {
state: string;
daemonId: string | null;
hubOrigin: string | null;
scopes: string[];
permissions: string[];
connectedAt: string | null;
lastError: string | null;
}
@@ -15,7 +15,15 @@ export interface HubProvidersSnapshotOptions {
}
export interface HubDaemonClient {
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
connectHub(
url: string,
token: string,
permissions?: readonly string[],
): Promise<{ status: HubStatus }>;
updateHubPermissions(input: {
grant?: readonly string[];
revoke?: readonly string[];
}): Promise<{ status: HubStatus }>;
getHubStatus(): Promise<{ status: HubStatus }>;
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
getProvidersSnapshot(
+5
View File
@@ -20,6 +20,7 @@ import { processHubReporter, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js";
import { addHubResolutionHelp } from "./help.js";
import { addHubInitCommand, continueHubGuidedSetup } from "./init.js";
import { addHubPermissionsCommand } from "./permissions.js";
interface HubCommandEnvironment {
env: Readonly<Record<string, string | undefined>>;
@@ -80,6 +81,10 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
daemon: environment.daemon,
reporter: environment.reporter,
});
addHubPermissionsCommand(hub, {
daemon: environment.daemon,
reporter: environment.reporter,
});
addHubProjectsCommand(hub, {
env: environment.env,
credentials: environment.credentials,
@@ -25,7 +25,7 @@ describe("Hub guided setup continuation", () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([true], [], []);
const prompts = new PromptAnswers([true, false], [], []);
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
@@ -42,9 +42,13 @@ describe("Hub guided setup continuation", () => {
},
);
assert.deepEqual(prompts.confirmations, ["Connect this daemon to this Hub?"]);
assert.deepEqual(prompts.confirmations, [
"Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.",
"Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.",
]);
assert.deepEqual(prompts.selections, []);
assert.deepEqual(prompts.messages, [
"Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute",
"Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init",
]);
assert.deepEqual(calls, [{ operation: "token", origin: "https://hub.test" }]);
@@ -70,6 +74,26 @@ describe("Hub guided setup continuation", () => {
]);
});
it("does not reopen onboarding or widen permissions for an existing connection", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new SetupDaemon();
await daemon.connectHub("https://hub.test", "existing-token", []);
const prompts = new PromptAnswers([], [], []);
await continueHubGuidedSetup(
"https://hub.test",
setupEnvironment(cwd, credentials, daemon, prompts, []),
);
assert.deepEqual(prompts.confirmations, []);
assert.deepEqual(prompts.messages, [
"This daemon is already connected to https://hub.test. Permissions: None.",
"Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init",
]);
});
it("does not require a Hub app connection during login", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
@@ -333,6 +357,7 @@ class SetupDaemon implements HubDaemonClient {
connections = 0;
readonly snapshotCwds: Array<string | undefined> = [];
private origin: string | null = null;
private currentPermissions: string[] = [];
constructor(
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
@@ -355,14 +380,26 @@ class SetupDaemon implements HubDaemonClient {
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
) {}
async connectHub(origin: string) {
async connectHub(origin: string, _token: string, permissions: readonly string[] = []) {
this.connections += 1;
this.origin = origin;
return { status: status(origin) };
this.currentPermissions = [...permissions];
return { status: status(origin, permissions) };
}
async updateHubPermissions(input: { grant?: readonly string[]; revoke?: readonly string[] }) {
const permissions = new Set(this.origin === null ? [] : this.currentPermissions);
for (const permission of input.revoke ?? []) permissions.delete(permission);
for (const permission of input.grant ?? []) permissions.add(permission);
this.currentPermissions = [...permissions];
return { status: status(this.origin ?? "https://hub.test", this.currentPermissions) };
}
async getHubStatus() {
return { status: this.origin === null ? disconnectedStatus() : status(this.origin) };
return {
status:
this.origin === null ? disconnectedStatus() : status(this.origin, this.currentPermissions),
};
}
async getProvidersSnapshot(options?: { cwd?: string }) {
@@ -381,12 +418,12 @@ class SetupDaemon implements HubDaemonClient {
async close() {}
}
function status(origin: string): HubStatus {
function status(origin: string, permissions: readonly string[] = []): HubStatus {
return {
state: "connected",
daemonId: "daemon-1",
hubOrigin: origin,
scopes: [],
permissions: [...permissions],
connectedAt: null,
lastError: null,
};
@@ -397,7 +434,7 @@ function disconnectedStatus(): HubStatus {
state: "not_connected",
daemonId: null,
hubOrigin: null,
scopes: [],
permissions: [],
connectedAt: null,
lastError: null,
};
@@ -68,7 +68,7 @@ describe("Hub init planning", () => {
state: "connected",
daemonId: "daemon-1",
hubOrigin: "https://hub.test",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
connectedAt: null,
lastError: null,
};
+52 -5
View File
@@ -191,8 +191,43 @@ export async function continueHubGuidedSetup(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<void> {
if (await requiredConfirm(environment, "Connect this daemon to this Hub?", true)) {
await ensureDaemonConnection(origin, environment, true);
const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
const current = resolveHubInitConnection(currentStatus, origin);
if (current.kind === "connected") {
reportMessage(
environment,
`This daemon is already connected to ${origin}. Permissions: ${
currentStatus.permissions.join(", ") || "None"
}.`,
);
} else if (current.kind === "pending") {
await waitForDaemonReady(origin, environment.daemon);
} else if (current.kind === "conflict") {
reportMessage(
environment,
`This daemon is connected to ${current.origin}. Disconnect it before connecting to ${origin}.`,
);
} else if (
await requiredConfirm(
environment,
"Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.",
true,
)
) {
const grantExecution = await requiredConfirm(
environment,
"Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.",
false,
);
await ensureDaemonConnection(origin, environment, true, grantExecution ? ["hub.execute"] : []);
if (!grantExecution) {
reportMessage(
environment,
"Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute",
);
}
} else {
reportMessage(
environment,
@@ -252,12 +287,19 @@ async function ensureDaemonConnection(
origin: string,
environment: HubGuidedSetupEnvironment,
confirmed = false,
permissions: readonly string[] = ["hub.execute"],
): Promise<string> {
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
const connection = resolveHubInitConnection(status, origin);
if (connection.kind === "connected") {
if (permissions.includes("hub.execute") && !status.permissions.includes("hub.execute")) {
throw new HubCommandError(
"HUB_DAEMON_EXECUTION_NOT_ALLOWED",
"This daemon is connected to Hub but cannot run Hub automations. Run `paseo hub permissions grant hub.execute`, then run Hub init again.",
);
}
return connection.daemonId;
}
if (connection.kind === "pending") {
@@ -271,20 +313,25 @@ async function ensureDaemonConnection(
}
if (
!confirmed &&
!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))
!(await requiredConfirm(
environment,
`Connect this daemon to ${origin} and allow Hub workflows to create and control workspaces and agents?`,
true,
))
) {
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
}
return connectDaemon(origin, environment);
return connectDaemon(origin, environment, permissions);
}
async function connectDaemon(
origin: string,
environment: HubGuidedSetupEnvironment,
permissions: readonly string[] = ["hub.execute"],
): Promise<string> {
await runHubConnect(
origin,
{},
{ permissions },
{
env: environment.env,
credentials: environment.credentials,
@@ -0,0 +1,112 @@
import type { Command } from "commander";
import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import type { HubDaemonConnection, HubStatus } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
import { reportHubProgress, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js";
interface HubPermissionsOptions {
host?: string;
json?: boolean;
}
interface HubPermissionsDependencies {
daemon: HubDaemonConnection;
reporter: HubReporter;
}
interface PermissionRow {
permission: string;
description: string;
}
const schema: OutputSchema<PermissionRow> = {
idField: "permission",
columns: [
{ header: "PERMISSION", field: "permission" },
{ header: "DESCRIPTION", field: "description" },
],
};
export function runHubPermissionsList(
options: HubPermissionsOptions,
dependencies: HubPermissionsDependencies,
): Promise<ListResult<PermissionRow>> {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
const status = (await client.getHubStatus()).status;
requireConnectedHub(status);
return {
type: "list",
data: status.permissions.map((permission) => ({
permission,
description: describePermission(permission),
})),
schema,
};
});
}
export function runHubPermissionChange(
operation: "grant" | "revoke",
permission: string,
options: HubPermissionsOptions,
dependencies: HubPermissionsDependencies,
) {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
const current = (await client.getHubStatus()).status;
requireConnectedHub(current);
const response = await client.updateHubPermissions(
operation === "grant" ? { grant: [permission] } : { revoke: [permission] },
);
reportHubProgress(
dependencies.reporter,
options,
`${operation === "grant" ? "Granted" : "Revoked"} ${permission} ${
operation === "grant" ? "to" : "from"
} ${response.status.hubOrigin}`,
);
return hubStatusResult(response.status);
});
}
export function addHubPermissionsCommand(
parent: Command,
dependencies: HubPermissionsDependencies,
): void {
const permissions = parent
.command("permissions")
.description("Manage this Hub's daemon permissions");
addJsonAndDaemonHostOptions(permissions.command("list")).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubPermissionsOptions;
return runHubPermissionsList(options, dependencies);
}),
);
for (const operation of ["grant", "revoke"] as const) {
addJsonAndDaemonHostOptions(
permissions.command(operation).argument("<permission>", "Daemon permission"),
).action(
withOutput(async (...args) => {
const permission = args[0] as string;
const options = args.at(-2) as HubPermissionsOptions;
return runHubPermissionChange(operation, permission, options, dependencies);
}),
);
}
}
function requireConnectedHub(status: HubStatus): void {
if (
status.hubOrigin === null ||
(status.state !== "connected" && status.state !== "reconnecting")
) {
throw new Error("This daemon is not connected to a Hub");
}
}
function describePermission(permission: string): string {
return permission === "hub.execute" ? "Run agents for Hub automations" : permission;
}
@@ -5,7 +5,7 @@ export interface HubRow {
state: string;
daemonId: string | null;
hub: string | null;
scopes: string;
permissions: string;
connectedAt: string | null;
error: string | null;
warning?: string;
@@ -17,7 +17,7 @@ const schema: OutputSchema<HubRow> = {
{ header: "STATE", field: "state" },
{ header: "HUB", field: "hub" },
{ header: "DAEMON", field: "daemonId" },
{ header: "SCOPES", field: "scopes" },
{ header: "PERMISSIONS", field: "permissions" },
{ header: "CONNECTED", field: "connectedAt" },
{ header: "ERROR", field: "error" },
{ header: "WARNING", field: "warning" },
@@ -36,7 +36,7 @@ export function hubStatusResult(
state: status.state,
daemonId: status.daemonId,
hub: reportedHubOrigin,
scopes: status.scopes.join(", "),
permissions: status.permissions.join(", "),
connectedAt: status.connectedAt,
error: status.lastError,
warning,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/relay": "0.7.0",
"zod": "^4.4.3"
},
"devDependencies": {
+24 -3
View File
@@ -309,7 +309,7 @@ export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteRe
export interface DaemonClientConfig {
url: string;
clientId: string;
clientType?: "mobile" | "browser" | "cli" | "mcp";
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
appVersion?: string;
runtimeGeneration?: number | null;
password?: string;
@@ -4680,15 +4680,36 @@ export class DaemonClient {
});
}
async connectHub(hubUrl: string, token: string, requestId?: string) {
async connectHub(
hubUrl: string,
token: string,
permissions: readonly string[] = [],
requestId?: string,
) {
this.requireHubRelationshipSupport();
return this.sendCorrelatedSessionRequest({
requestId,
message: { type: "hub.management.daemon.connect.request", hubUrl, token },
message: { type: "hub.management.daemon.connect.request", hubUrl, token, permissions },
responseType: "hub.management.daemon.connect.response",
});
}
async updateHubPermissions(
input: { grant?: readonly string[]; revoke?: readonly string[] },
requestId?: string,
) {
this.requireHubRelationshipSupport();
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "hub.management.daemon.permissions.update.request",
grant: input.grant ?? [],
revoke: input.revoke ?? [],
},
responseType: "hub.management.daemon.permissions.update.response",
});
}
async getHubStatus(requestId?: string) {
this.requireHubRelationshipSupport();
return this.sendCorrelatedSessionRequest({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -44,8 +44,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -54,8 +54,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"react": "19.1.0",
"zod": "^4.4.3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+24 -3
View File
@@ -335,9 +335,16 @@ describe("Hub session protocol", () => {
requestId: "r1",
hubUrl: "https://hub.example",
token: "token",
permissions: [],
},
{ type: "hub.management.daemon.get_status.request", requestId: "r2" },
{ type: "hub.management.daemon.disconnect.request", requestId: "r3", force: true },
{
type: "hub.management.daemon.permissions.update.request",
requestId: "r4",
grant: ["hub.execute"],
revoke: [],
},
])("accepts trusted management request $type", (message) => {
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
});
@@ -351,7 +358,7 @@ describe("Hub session protocol", () => {
state: "connected",
daemonId: "daemon-1",
hubOrigin: "https://hub.example",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
connectedAt: "2026-07-13T00:00:00.000Z",
lastError: null,
},
@@ -365,7 +372,7 @@ describe("Hub session protocol", () => {
state: "not_connected",
daemonId: null,
hubOrigin: null,
scopes: [],
permissions: [],
connectedAt: null,
lastError: null,
},
@@ -379,13 +386,27 @@ describe("Hub session protocol", () => {
state: "disconnecting",
daemonId: "daemon-1",
hubOrigin: "https://hub.example",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
connectedAt: null,
lastError: "offline",
},
warning: "pending",
},
},
{
type: "hub.management.daemon.permissions.update.response",
payload: {
requestId: "r4",
status: {
state: "connected",
daemonId: "daemon-1",
hubOrigin: "https://hub.example",
permissions: ["hub.execute"],
connectedAt: "2026-07-13T00:00:00.000Z",
lastError: null,
},
},
},
])("accepts trusted management response $type", (message) => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
+31 -2
View File
@@ -93,6 +93,20 @@ export {
// Mutable daemon config schemas (shared between server store and client)
// ---------------------------------------------------------------------------
export const DAEMON_PERMISSIONS = [
"daemon.read",
"daemon.manage",
"tunnel.manage",
"access.manage",
"workspace.read",
"workspace.write",
"workspace.manage",
"automation.manage",
"hub.execute",
] as const;
export const DaemonPermissionSchema = z.enum(DAEMON_PERMISSIONS);
export type DaemonPermission = z.infer<typeof DaemonPermissionSchema>;
const MutableDaemonProviderModelSchema = z
.object({
id: z.string().min(1),
@@ -1393,6 +1407,7 @@ export const HubManagementDaemonConnectRequestSchema = z.object({
requestId: z.string(),
hubUrl: z.string(),
token: z.string(),
permissions: z.array(DaemonPermissionSchema).default([]),
});
export const HubManagementDaemonGetStatusRequestSchema = z.object({
type: z.literal("hub.management.daemon.get_status.request"),
@@ -1403,6 +1418,12 @@ export const HubManagementDaemonDisconnectRequestSchema = z.object({
requestId: z.string(),
force: z.boolean().optional(),
});
export const HubManagementDaemonPermissionsUpdateRequestSchema = z.object({
type: z.literal("hub.management.daemon.permissions.update.request"),
requestId: z.string(),
grant: z.array(DaemonPermissionSchema).default([]),
revoke: z.array(DaemonPermissionSchema).default([]),
});
export const DiagnosticsRequestSchema = z.object({
type: z.literal("diagnostics.request"),
@@ -3011,6 +3032,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HubManagementDaemonConnectRequestSchema,
HubManagementDaemonGetStatusRequestSchema,
HubManagementDaemonDisconnectRequestSchema,
HubManagementDaemonPermissionsUpdateRequestSchema,
DiagnosticsRequestSchema,
PluginCatalogGetRequestSchema,
PluginListRequestSchema,
@@ -3325,6 +3347,8 @@ export const ServerInfoStatusPayloadSchema = z
serverId: z.string().trim().min(1),
hostname: ServerInfoHostnameSchema.optional(),
version: ServerInfoVersionSchema.optional(),
// COMPAT(sessionPermissions): optional while clients support older daemons.
permissions: z.array(DaemonPermissionSchema).optional(),
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
desktopManaged: z.boolean().optional(),
capabilities: ServerCapabilitiesFromUnknownSchema.optional(),
@@ -4599,7 +4623,7 @@ export const HubRelationshipStatusSchema = z.object({
]),
daemonId: z.string().nullable(),
hubOrigin: z.string().nullable(),
scopes: z.array(z.string()),
permissions: z.array(DaemonPermissionSchema),
connectedAt: z.string().nullable(),
lastError: z.string().nullable(),
});
@@ -4619,6 +4643,10 @@ export const HubManagementDaemonDisconnectResponseSchema = z.object({
warning: z.string().optional(),
}),
});
export const HubManagementDaemonPermissionsUpdateResponseSchema = z.object({
type: z.literal("hub.management.daemon.permissions.update.response"),
payload: z.object({ requestId: z.string(), status: HubRelationshipStatusSchema }),
});
export const DaemonGetPairingOfferResponseSchema = z.object({
type: z.literal("daemon.get_pairing_offer.response"),
@@ -6362,6 +6390,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubManagementDaemonConnectResponseSchema,
HubManagementDaemonGetStatusResponseSchema,
HubManagementDaemonDisconnectResponseSchema,
HubManagementDaemonPermissionsUpdateResponseSchema,
DiagnosticsResponseSchema,
GetDaemonConfigResponseMessageSchema,
SetDaemonConfigResponseMessageSchema,
@@ -6936,7 +6965,7 @@ export const WSPongMessageSchema = z.object({
export const WSHelloMessageSchema = z.object({
type: z.literal("hello"),
clientId: z.string().min(1),
clientType: z.enum(["mobile", "browser", "cli", "mcp"]),
clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]),
protocolVersion: z.number().int(),
appVersion: z.string().optional(),
capabilities: z
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -72,11 +72,11 @@
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/highlight": "0.7.0-beta.1",
"@getpaseo/plugin": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/client": "0.7.0",
"@getpaseo/highlight": "0.7.0",
"@getpaseo/plugin": "0.7.0",
"@getpaseo/protocol": "0.7.0",
"@getpaseo/relay": "0.7.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -129,9 +129,9 @@ second line'`,
const result = curateAgentActivity(timeline);
expect(result).toContain("[Exec Command]");
expect(result).toContain("[Read File]");
expect(result).toContain("[Web Search]");
expect(result).toContain("[Exec command]");
expect(result).toContain("[Read file]");
expect(result).toContain("[Web search]");
expect(result).not.toContain("npm run lint");
expect(result).not.toContain("src/index.ts");
expect(result).not.toContain("zod union");
@@ -5543,6 +5543,88 @@ test("waitForAgentRunStart resolves while a foreground run is still only pending
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
});
test("waitForAgentRunStart ignores a prior turn error while the next run starts", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-error-resume-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const retryStartEntered = deferred<void>();
const releaseRetryStart = deferred<void>();
class ErrorThenResumeSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
const turnId = `turn-${++this.turnIdCounter}`;
if (this.turnIdCounter === 1) {
void (async () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({
type: "turn_failed",
provider: this.provider,
turnId,
error: "model at capacity",
});
})();
return { turnId };
}
retryStartEntered.resolve();
await releaseRetryStart.promise;
void (async () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
})();
return { turnId };
}
}
class ErrorThenResumeClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ErrorThenResumeSession(config);
}
}
const manager = new AgentManager({
clients: { codex: new ErrorThenResumeClient() },
registry: storage,
logger,
});
let agentId: string | null = null;
try {
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = snapshot.id;
await manager.runAgent(agentId, "fail").catch(() => undefined);
expect(manager.getAgent(agentId)?.lifecycle).toBe("error");
const dispatch = await startAgentRun(manager, agentId, "resume", logger);
expect(dispatch.disposition).toBe("turn_started");
const wait = manager.waitForAgentRunStart(agentId);
let earlyResult: "pending" | "resolved" | "rejected" = "pending";
void wait.then(
() => {
earlyResult = "resolved";
return earlyResult;
},
() => {
earlyResult = "rejected";
return earlyResult;
},
);
await retryStartEntered.promise;
await Promise.resolve();
expect(earlyResult).toBe("pending");
releaseRetryStart.resolve();
await expect(wait).resolves.toBeUndefined();
await manager.waitForAgentEvent(agentId);
} finally {
releaseRetryStart.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-"));
const storagePath = join(workdir, "agents");
@@ -2165,6 +2165,7 @@ export class AgentManager {
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
@@ -2232,8 +2233,7 @@ export class AgentManager {
agent.pendingReplacement = false;
}
const turnStartedAt = new Date();
pendingRun.started = true;
pendingRun.turnId = turnId;
pendingRun.start = { status: "started", turnId };
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.lifecycle = "running";
@@ -2575,7 +2575,10 @@ export class AgentManager {
}
const pendingRun = this.runs.getPendingRun(agentId);
if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) {
if (
(snapshot.lifecycle === "running" || pendingRun?.start.status === "started") &&
!snapshot.pendingReplacement
) {
return;
}
@@ -2640,14 +2643,19 @@ export class AgentManager {
const currentPendingRun = this.runs.getPendingRun(agentId);
if (
(current.lifecycle === "running" || currentPendingRun?.started) &&
(current.lifecycle === "running" || currentPendingRun?.start.status === "started") &&
!current.pendingReplacement
) {
finishOk();
return true;
}
if (current.lifecycle === "error" && !currentPendingRun?.started) {
if (currentPendingRun?.start.status === "failed") {
finishErr(new Error(currentPendingRun.start.error));
return true;
}
if (current.lifecycle === "error" && !currentPendingRun) {
finishErr(new Error(current.lastError ?? `Agent ${agentId} failed to start`));
return true;
}
@@ -2734,16 +2742,17 @@ export class AgentManager {
return { status: settlement === "completed" ? "settled" : "refused" };
}
if (settlement === "timed_out" && run.turnId) {
const runTurnId = this.runs.getTurnId(agentId);
if (settlement === "timed_out" && runTurnId) {
this.logger.warn(
{ agentId, turnId: run.turnId, kind: run.kind },
{ agentId, turnId: runTurnId, kind: run.kind },
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
);
await this.dispatchSessionEvent(agent, {
type: "turn_canceled",
provider: agent.provider,
reason: "interrupted",
turnId: run.turnId,
turnId: runTurnId,
});
await run.settledPromise;
} else if (settlement === "timed_out" && run.kind === "foreground") {
@@ -3023,7 +3032,7 @@ export class AgentManager {
let hasStarted =
isAgentBusy(initialStatus) ||
Boolean(snapshot.activeForegroundTurnId) ||
Boolean(pendingForegroundRun?.started);
pendingForegroundRun?.start.status === "started";
let terminalStatusOverride: AgentLifecycleStatus | null = null;
let finished = false;
@@ -3429,7 +3438,7 @@ export class AgentManager {
return;
}
const pendingRun = this.runs.getPendingRun(agentId);
if (pendingRun && !pendingRun.started) {
if (pendingRun?.start.status === "pending") {
pendingRun.stagedEvents.push(event);
return;
}
@@ -14,8 +14,10 @@ export interface PendingForegroundRun {
token: string;
kind: "foreground";
stagedEvents: AgentStreamEvent[];
turnId: string | null;
started: boolean;
start:
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
@@ -64,6 +66,13 @@ export class AgentRunState {
return this.runs.has(agentId);
}
getTurnId(agentId: string): string | null {
const run = this.runs.get(agentId);
if (!run) return null;
if (run.kind === "autonomous") return run.turnId;
return run.start.status === "started" ? run.start.turnId : null;
}
trackAutonomousRun(agentId: string, turnId: string | null): TrackedAgentRun {
const current = this.runs.get(agentId);
if (current) {
@@ -85,7 +94,10 @@ export class AgentRunState {
if (!run) {
return;
}
if (run.kind === "foreground" && (run.turnId === null || run.turnId !== turnId)) {
if (
run.kind === "foreground" &&
(run.start.status !== "started" || run.start.turnId !== turnId)
) {
return;
}
if (
@@ -268,8 +280,7 @@ function createPendingForegroundRun(): PendingForegroundRun {
return {
...createTrackedRunState(),
kind: "foreground",
turnId: null,
started: false,
start: { status: "pending" },
stagedEvents: [],
};
}
@@ -54,6 +54,14 @@ readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line
});
`;
const CLOSED_STDIN_CHILD_SOURCE = String.raw`
const fs = require("node:fs");
fs.closeSync(0);
process.stdout.write(JSON.stringify({ type: "stdin_closed" }) + "\n");
setInterval(() => {}, 1_000);
`;
interface InMemoryChildProcess extends ChildProcessWithoutNullStreams {
stdin: PassThrough;
stdout: PassThrough;
@@ -63,6 +71,7 @@ interface InMemoryChildProcess extends ChildProcessWithoutNullStreams {
interface StartProcessOptions {
child?: ChildProcessWithoutNullStreams;
defaultRequestTimeoutMs?: number;
source?: string;
}
function createInMemoryChildProcess(): InMemoryChildProcess {
@@ -85,7 +94,7 @@ function startProcess(options: StartProcessOptions = {}): JsonlRpcProcess {
return new JsonlRpcProcess({
launch: {
command: process.execPath,
args: ["-e", CHILD_SOURCE, "--", "resolved-arg"],
args: ["-e", options.source ?? CHILD_SOURCE, "--", "resolved-arg"],
cwd: process.cwd(),
env: { JSONL_RPC_TEST_VALUE: "resolved-env" },
},
@@ -222,6 +231,70 @@ describe("JsonlRpcProcess", () => {
await rejection;
});
test("closes the transport when a direct send synchronously throws EPIPE", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
child.stdin.write = () => {
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
};
expect(() => transport.send({ type: "notice" })).not.toThrow();
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
"JSONL RPC process is closed",
);
});
// Closing fd 0 does not sever the inherited pipe on Windows, so this fixture cannot produce EPIPE.
test.skipIf(process.platform === "win32")(
"keeps the parent alive when a real child closes its stdin pipe",
async () => {
const transport = startProcess({ source: CLOSED_STDIN_CHILD_SOURCE });
const stdinClosed = new Promise<void>((resolve) => {
const unsubscribe = transport.onMessage((message) => {
if (message.type !== "stdin_closed") return;
unsubscribe();
resolve();
});
});
await stdinClosed;
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
/EPIPE|stdin is not writable/,
);
await expect(transport.request({ type: "echo", value: "later" })).rejects.toThrow(
"JSONL RPC process is closed",
);
},
);
test("stdin error events close the transport instead of becoming uncaught exceptions", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
const request = transport.request({ type: "hang" });
const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
child.stdin.emit("error", err);
await expect(request).rejects.toThrow("write EPIPE");
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
"JSONL RPC process is closed",
);
});
test("a non-writable stdin closes the transport instead of hanging the request", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
child.stdin.end();
await expect(transport.request({ type: "hang" })).rejects.toThrow(
"JSONL RPC stdin is not writable",
);
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
"JSONL RPC process is closed",
);
});
test("reassembles protocol v2 chunked responses into the logical frame", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
@@ -104,6 +104,9 @@ export class JsonlRpcProcess {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
this.child.stdin.on("error", (error) => {
this.handleStdinError(error);
});
this.child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
@@ -173,10 +176,18 @@ export class JsonlRpcProcess {
}
send(message: Record<string, unknown>): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
if (this.disposed) {
return;
}
this.child.stdin.write(`${JSON.stringify(message)}\n`);
if (this.child.stdin.destroyed || !this.child.stdin.writable) {
this.handleStdinError(new Error(`${this.diagnosticName} stdin is not writable`));
return;
}
try {
this.child.stdin.write(`${JSON.stringify(message)}\n`);
} catch (error) {
this.handleStdinError(error);
}
}
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
@@ -243,6 +254,15 @@ export class JsonlRpcProcess {
pending.resolve(response.data);
}
private handleStdinError(error: unknown): void {
if (this.disposed) {
return;
}
const err = error instanceof Error ? error : new Error(String(error));
this.options.logger.warn({ err }, `${this.diagnosticName} stdin write failed`);
void this.close(err);
}
private failAll(error: Error): void {
if (this.disposed) {
return;
@@ -6361,7 +6361,7 @@ describe("OpenCode provider subagent contract", () => {
messageID: "msg_child_prompt",
type: "text",
text: "Inspect the auth flow.",
time: { start: 1, end: 2 },
time: { start: 1 },
},
},
});
@@ -2811,7 +2811,7 @@ function appendOpenCodeTextPart(
events: AgentStreamEvent[],
): void {
if (messageRole === "user") {
if (!part.time?.end || !part.text || state.emittedUserMessageIds?.has(part.messageID)) {
if (!part.text || state.emittedUserMessageIds?.has(part.messageID)) {
return;
}
state.emittedUserMessageIds?.add(part.messageID);
@@ -10,6 +10,7 @@ import {
OWNER_PERMISSIONS,
SessionAuthorization,
permissionsForLegacyHubScopes,
parseDaemonPermissions,
} from "./index.js";
function inboundOperationTypes(): SessionInboundMessage["type"][] {
@@ -71,4 +72,9 @@ describe("SessionAuthorization", () => {
),
).toBe(true);
});
test("permission parsing validates against the shared registry and removes duplicates", () => {
expect(parseDaemonPermissions(["hub.execute", "hub.execute"])).toEqual(["hub.execute"]);
expect(() => parseDaemonPermissions(["hub.execution.*"])).toThrow("Invalid daemon permission");
});
});
@@ -1,22 +1,23 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "../messages.js";
import { DAEMON_PERMISSIONS, type DaemonPermission } from "@getpaseo/protocol/messages";
import {
requiredPermissionForInbound,
requiredPermissionForOutbound,
} from "./operation-permissions.js";
export const DAEMON_PERMISSIONS = [
"daemon.read",
"daemon.manage",
"tunnel.manage",
"access.manage",
"workspace.read",
"workspace.write",
"workspace.manage",
"automation.manage",
"hub.execute",
] as const;
export { DAEMON_PERMISSIONS, type DaemonPermission };
export type DaemonPermission = (typeof DAEMON_PERMISSIONS)[number];
const daemonPermissionSet: ReadonlySet<string> = new Set(DAEMON_PERMISSIONS);
export function isDaemonPermission(value: string): value is DaemonPermission {
return daemonPermissionSet.has(value);
}
export function parseDaemonPermissions(values: readonly string[]): DaemonPermission[] {
const permissions = [...new Set(values)];
if (!permissions.every(isDaemonPermission)) throw new Error("Invalid daemon permission");
return permissions;
}
export const OWNER_PERMISSIONS: readonly DaemonPermission[] = DAEMON_PERMISSIONS;
@@ -39,6 +40,14 @@ export class SessionAuthorization {
this.permissions = new Set(permissions);
}
listPermissions(): DaemonPermission[] {
return [...this.permissions];
}
allowsPermission(permission: DaemonPermission): boolean {
return this.permissions.has(permission);
}
private allows(permission: DaemonPermission | null): boolean {
return permission === null || this.permissions.has(permission);
}
@@ -96,6 +96,7 @@ const INBOUND_PERMISSION = {
"hub.management.daemon.connect.request": "tunnel.manage",
"hub.management.daemon.disconnect.request": "tunnel.manage",
"hub.management.daemon.get_status.request": "tunnel.manage",
"hub.management.daemon.permissions.update.request": "access.manage",
import_agent_request: "workspace.write",
kill_terminal_request: "workspace.write",
list_available_editors_request: "workspace.read",
@@ -305,6 +306,7 @@ const OUTBOUND_PERMISSION = {
"hub.management.daemon.connect.response": "tunnel.manage",
"hub.management.daemon.disconnect.response": "tunnel.manage",
"hub.management.daemon.get_status.response": "tunnel.manage",
"hub.management.daemon.permissions.update.response": "access.manage",
kill_terminal_response: "workspace.write",
list_available_editors_response: "workspace.read",
list_available_providers_response: "daemon.read",
@@ -536,10 +536,13 @@ describe("paseo daemon bootstrap", () => {
await enrollmentReleased;
return {
daemonId: input.daemonId,
scopes: input.scopes,
permissions: input.permissions,
webSocketUrl: "wss://hub.test/daemon",
};
},
async updatePermissions(input) {
return { permissions: input.permissions };
},
async revoke(_input: HubRevocation): Promise<void> {},
openSocket(_input: HubSocketCredentials, _events: HubSocketEvents): HubSocketConnection {
return { close: () => undefined };
+27 -7
View File
@@ -671,7 +671,7 @@ export async function createPaseoDaemon(
serviceProxy,
onChange: createScriptStatusEmitter({
sessions: () =>
wsServer?.listTrustedSessions().map((session) => ({
wsServer?.listSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message),
})) ?? [],
serviceProxy,
@@ -951,7 +951,7 @@ export async function createPaseoDaemon(
onWorkspaceArchived: teardownArchivedWorkspaceRuntime,
onWorkspacesChanged: async (workspaceIds) => {
await fanOutReconciledWorkspaceUpdates({
sessions: wsServer?.listTrustedSessions() ?? [],
sessions: wsServer?.listSessions() ?? [],
workspaceIds,
logger,
});
@@ -1015,20 +1015,20 @@ export async function createPaseoDaemon(
};
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listTrustedSessions() ?? []) {
for (const session of wsServer?.listSessions() ?? []) {
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
}
};
const clearWorkspaceArchivingExternal = (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listTrustedSessions() ?? []) {
for (const session of wsServer?.listSessions() ?? []) {
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
}
};
const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
await Promise.all(
(wsServer?.listTrustedSessions() ?? []).map((session) =>
(wsServer?.listSessions() ?? []).map((session) =>
session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList),
),
);
@@ -1108,7 +1108,7 @@ export async function createPaseoDaemon(
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listTrustedSessions()
?.listSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
@@ -1206,7 +1206,27 @@ export async function createPaseoDaemon(
createDaemonId: dependencies.createHubDaemonId,
attachSocket: async (socket, options) => {
if (!wsServer) throw new Error("WebSocket server is not running");
await wsServer.attachHubSocket(socket, options);
await wsServer.attachExternalSocket(
socket,
{ transport: "hub", hubDaemonId: options.daemonId },
{
principalId: options.principalId,
permissions: options.permissions,
hubExecutionAgents: options.agents,
},
options.sessionProtocol === "legacy"
? {
type: "hello",
clientId: `hub:${options.daemonId}`,
clientType: "hub",
protocolVersion: 1,
}
: undefined,
);
},
updateAttachedPermissions: (principalId, permissions) => {
if (!wsServer) throw new Error("WebSocket server is not running");
wsServer.updatePrincipalPermissions(principalId, permissions);
},
createExecutionAgents: (daemonId) =>
new DaemonExecutions({
@@ -35,7 +35,7 @@ test("Hub retries one durable daemon execution across concurrency and reconstruc
expect(reconstructed.durableAgentCount).toBe(1);
});
test("Hub denies trusted steering and browser dispatch", async () => {
test("Hub session applies its scoped authorization to ordinary protocol messages", async () => {
const hub = await launchRelationship();
const localAgentId = await hub.createUnrelatedLocalAgent();
@@ -55,22 +55,34 @@ test("Hub denies trusted steering and browser dispatch", async () => {
code: "access_denied",
});
expect(hub.observedAgentIds()).not.toContain(localAgentId);
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
});
test("Hub sockets reject trusted hello and capabilities", async () => {
test("Hub completes the standard hello before rejecting a second hello", async () => {
const hub = await launchRelationship();
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
expect(hub.probeTrustedHello()).toBe(4002);
});
test("Hub sockets reject trusted binary frames", async () => {
const hub = await launchRelationship();
test("legacy Hub wire behavior still enters the common Session bootstrap", async () => {
const launched = await HubRelationshipHarness.start();
await launched.beginConnect().result;
launched.connectLatestLegacySocket();
relationship = launched;
expect(hub.probeBinaryFrame()).toBe(4002);
expect(launched.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
expect(launched.serverInfoPermissions()).toEqual([["hub.execute"]]);
});
test("Hub does not receive trusted broadcasts", async () => {
test("Hub binary frames enter the standard active-session path", async () => {
const hub = await launchRelationship();
expect(hub.probeBinaryFrame()).toBeNull();
});
test("Hub receives standard server info but not broadcasts outside its scope", async () => {
const hub = await launchRelationship();
const trustedBroadcasts = await hub.trustedBroadcastCount();
@@ -78,10 +90,10 @@ test("Hub does not receive trusted broadcasts", async () => {
expect(trustedBroadcasts).toBe(0);
expect(trustedStatus).toMatchObject({ pid: process.pid, relay: { enabled: false } });
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
});
test("Hub reconnects without retaining trusted session state", async () => {
test("Hub reconnects through the standard resumable session bootstrap", async () => {
const hub = await launchRelationship();
const created = await hub.createOwnedConcurrently();
@@ -91,7 +103,8 @@ test("Hub reconnects without retaining trusted session state", async () => {
executionId: "execution-1",
agentId: created.first.agentId,
});
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info", "server_info"]);
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"], ["hub.execute"]]);
});
test("Hub interrupts an owned running execution idempotently", async () => {
@@ -98,27 +98,68 @@ describe("Hub relationship", () => {
});
});
test("Hub enrollment cannot widen the daemon's locally granted scopes", async () => {
test("new relationships can connect without granting Hub execution", async () => {
relationship = await HubRelationshipHarness.start();
relationship.returnEnrollmentScopes(["hub.execution.*", "*"]);
relationship.returnEnrollmentPermissions([]);
await relationship.beginConnect().result;
await relationship.beginConnect("registered-token", "https://hub.example", false).result;
relationship.connectLatestSocket();
const responses = relationship.sendHubRequestOnLatest({
type: "daemon.get_status.request",
requestId: "scope-escalation",
type: "hub.execution.agent.create.request",
requestId: "registered-execution",
executionId: "execution-1",
provider: "codex",
cwd: "/workspace",
prompt: "Do not run",
});
expect(relationship.relationshipFile()?.relationship.scopes).toEqual(["hub.execution.*"]);
expect(relationship.relationshipFile()?.relationship.permissions).toEqual([]);
expect(responses).toContainEqual({
type: "rpc_error",
payload: {
requestId: "scope-escalation",
requestType: "daemon.get_status.request",
error: "Session is not authorized for daemon.get_status.request",
requestId: "registered-execution",
requestType: "hub.execution.agent.create.request",
error: "Session is not authorized for hub.execution.agent.create.request",
code: "access_denied",
},
});
await relationship.grantPermission("hub.execute");
expect(relationship.relationshipFile()?.relationship.permissions).toEqual(["hub.execute"]);
relationship.beginOwnedCreate("granted-execution", "execution-after-grant");
await expect(relationship.ownedCreateResult("granted-execution")).resolves.toMatchObject({
payload: { success: true, executionId: "execution-after-grant" },
});
await relationship.revokePermission("hub.execute");
expect(relationship.relationshipFile()?.relationship.permissions).toEqual([]);
const revokedResponses = relationship.sendHubRequestOnLatest({
type: "hub.execution.agent.create.request",
requestId: "revoked-execution",
executionId: "execution-after-revoke",
provider: "codex",
cwd: "/workspace",
prompt: "Do not run",
});
expect(revokedResponses).toContainEqual({
type: "rpc_error",
payload: {
requestId: "revoked-execution",
requestType: "hub.execution.agent.create.request",
error: "Session is not authorized for hub.execution.agent.create.request",
code: "access_denied",
},
});
});
test("Hub enrollment cannot widen the daemon's locally granted permissions", async () => {
relationship = await HubRelationshipHarness.start();
relationship.returnEnrollmentPermissions(["hub.execute", "daemon.manage"]);
await relationship.beginConnect().result;
expect(relationship.relationshipFile()?.relationship.permissions).toEqual(["hub.execute"]);
expect((await relationship.status()).state).toBe("reconnecting");
expect(relationship.socketAttempts()).toBe(0);
});
test("a lost enrollment response reuses the exact ceremony", async () => {
@@ -232,6 +273,33 @@ describe("Hub relationship", () => {
},
);
test("legacy execution scope migrates once to the semantic permission", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.corruptRelationshipFile(
JSON.stringify({
version: 1,
state: "active",
relationship: {
daemonId: "daemon-legacy",
idempotencyKey: "ceremony-legacy",
hubOrigin: "https://hub.test",
createdAt: "2026-07-13T00:00:00.000Z",
scopes: ["hub.execution.*"],
},
credential: { secret: "credential" },
transport: { kind: "direct_websocket", webSocketUrl: "wss://hub.test/daemon" },
}),
);
await relationship.startStoppedDaemon();
expect(relationship.relationshipFile()).toMatchObject({
version: 2,
relationship: { permissions: ["hub.execute"] },
});
expect(await relationship.status()).toMatchObject({ permissions: "hub.execute" });
});
test.each([
["a non-HTTP scheme", "ftp://hub.test"],
["embedded credentials", "https://user:password@hub.test"],
@@ -264,12 +332,12 @@ describe("Hub relationship", () => {
expect(relationship.enrollmentAttempts()).toEqual([]);
});
test("a persisted Hub relationship cannot widen its local execution scope", async () => {
test("a persisted Hub relationship cannot widen its local permissions", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
const persisted = relationship.relationshipFile();
expect(persisted).not.toBeNull();
persisted!.relationship.scopes = ["*"];
persisted!.relationship.permissions = ["*"];
await relationship.corruptRelationshipFile(JSON.stringify(persisted));
const socketAttemptsBeforeRestart = relationship.socketAttempts();
@@ -744,14 +812,14 @@ describe("Hub relationship", () => {
state: "revoked",
daemonId: enrollment.daemonId,
hub: "https://hub.test",
scopes: "hub.execution.*",
permissions: "hub.execute",
error: reason,
});
expect(persisted?.state).toBe("revoked");
expect(persisted?.relationship).toMatchObject({
daemonId: enrollment.daemonId,
hubOrigin: "https://hub.test",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
});
expect(persisted?.reason).toBe(reason);
expect(persisted).not.toHaveProperty("credential");
@@ -5,6 +5,12 @@ import type pino from "pino";
import { z } from "zod";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.js";
import type { WebSocketLike } from "../websocket-server.js";
import {
isDaemonPermission,
parseDaemonPermissions,
permissionsForLegacyHubScopes,
type DaemonPermission,
} from "../authorization/index.js";
import type { HubExecutionAgents } from "./daemon-executions.js";
import type {
HubRelationshipRemote,
@@ -15,8 +21,6 @@ import { HubEnrollmentRejectedError } from "./relationship-remote.js";
import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js";
const FILE_NAME = "hub-relationship.json";
const HUB_EXECUTION_SCOPE = "hub.execution.*";
const SCOPES = [HUB_EXECUTION_SCOPE] as const;
const HubOriginSchema = z
.string()
.url()
@@ -31,12 +35,22 @@ const HubOriginSchema = z
}
});
const DaemonPermissionsSchema = z
.array(z.string())
.superRefine((permissions, context) => {
for (const permission of permissions) {
if (!isDaemonPermission(permission)) {
context.addIssue({ code: "custom", message: `Invalid daemon permission: ${permission}` });
}
}
})
.transform(parseDaemonPermissions);
const RelationshipSchema = z.object({
daemonId: z.string().min(1),
idempotencyKey: z.string().min(1),
hubOrigin: HubOriginSchema,
createdAt: z.string(),
scopes: z.tuple([z.literal(HUB_EXECUTION_SCOPE)]),
permissions: DaemonPermissionsSchema,
});
const SanitizedRelationshipSchema = RelationshipSchema.omit({ idempotencyKey: true });
const CredentialSchema = z.object({ secret: z.string().min(1) });
@@ -49,7 +63,7 @@ const TransportSchema = z.object({
.refine((value) => new URL(value).hash === ""),
});
const PendingSchema = z.object({
version: z.literal(1),
version: z.literal(2),
state: z.literal("pending"),
relationship: RelationshipSchema,
credential: CredentialSchema,
@@ -57,27 +71,27 @@ const PendingSchema = z.object({
identity: z.object({ serverId: z.string().min(1), daemonPublicKey: z.string().min(1) }),
});
const ActiveSchema = z.object({
version: z.literal(1),
version: z.literal(2),
state: z.literal("active"),
relationship: RelationshipSchema,
credential: CredentialSchema,
transport: TransportSchema,
});
const DisconnectingSchema = z.object({
version: z.literal(1),
version: z.literal(2),
state: z.literal("disconnecting"),
relationship: RelationshipSchema,
credential: CredentialSchema,
transport: TransportSchema.optional(),
});
const RevokedSchema = z.object({
version: z.literal(1),
version: z.literal(2),
state: z.literal("revoked"),
relationship: SanitizedRelationshipSchema,
transport: TransportSchema.optional(),
reason: z.string().optional(),
});
const RecordSchema = z
const CurrentRecordSchema = z
.discriminatedUnion("state", [PendingSchema, ActiveSchema, DisconnectingSchema, RevokedSchema])
.superRefine((record, context) => {
if (!("transport" in record) || !record.transport) return;
@@ -91,6 +105,7 @@ const RecordSchema = z
message: "Hub WebSocket URL must match the Hub origin",
});
});
const RecordSchema = z.preprocess(migrateLegacyRecord, CurrentRecordSchema);
type PendingRecord = z.infer<typeof PendingSchema>;
type ActiveRecord = z.infer<typeof ActiveSchema>;
type DisconnectingRecord = z.infer<typeof DisconnectingSchema>;
@@ -109,13 +124,21 @@ export interface HubRelationshipStatus {
state: HubConnectionState;
daemonId: string | null;
hubOrigin: string | null;
scopes: string[];
permissions: DaemonPermission[];
connectedAt: string | null;
lastError: string | null;
}
export interface HubRelationshipManagement {
connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus>;
connect(input: {
hubUrl: string;
token: string;
permissions: readonly string[];
}): Promise<HubRelationshipStatus>;
updatePermissions(input: {
grant: readonly string[];
revoke: readonly string[];
}): Promise<HubRelationshipStatus>;
status(): HubRelationshipStatus;
disconnect(input: {
force: boolean;
@@ -147,8 +170,18 @@ export interface HubRelationshipControllerOptions {
createDaemonId?: () => string;
attachSocket: (
socket: WebSocketLike,
options: { daemonId: string; scopes: readonly string[]; agents: HubExecutionAgents },
options: {
daemonId: string;
principalId: string;
permissions: readonly DaemonPermission[];
agents: HubExecutionAgents;
sessionProtocol: "legacy" | "session-v1";
},
) => Promise<void>;
updateAttachedPermissions: (
principalId: string,
permissions: readonly DaemonPermission[],
) => void;
createExecutionAgents: (daemonId: string) => HubExecutionAgents;
}
@@ -241,13 +274,18 @@ export class HubRelationshipController implements HubRelationshipManagement {
state: this.state,
daemonId: this.record?.relationship.daemonId ?? null,
hubOrigin: this.record?.relationship.hubOrigin ?? null,
scopes: this.record?.relationship.scopes.slice() ?? [],
permissions: this.record?.relationship.permissions.slice() ?? [],
connectedAt: this.connectedAt,
lastError: this.lastError,
};
}
async connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus> {
async connect(input: {
hubUrl: string;
token: string;
permissions: readonly string[];
}): Promise<HubRelationshipStatus> {
const permissions = parseDaemonPermissions(input.permissions);
if (this.record?.state === "pending") {
if (normalizeHubUrl(input.hubUrl) !== this.record.relationship.hubOrigin) {
throw new Error("A pending Hub enrollment already exists for a different Hub");
@@ -264,14 +302,14 @@ export class HubRelationshipController implements HubRelationshipManagement {
throw new Error("This daemon already has a Hub relationship");
}
const pending: PendingRecord = {
version: 1,
version: 2,
state: "pending",
relationship: {
daemonId: this.options.createDaemonId?.() ?? randomUUID(),
idempotencyKey: randomUUID(),
hubOrigin: normalizeHubUrl(input.hubUrl),
createdAt: this.clock.now().toISOString(),
scopes: [...SCOPES],
permissions,
},
credential: { secret: randomBytes(32).toString("base64url") },
enrollment: { token: input.token },
@@ -285,6 +323,37 @@ export class HubRelationshipController implements HubRelationshipManagement {
return this.status();
}
async updatePermissions(input: {
grant: readonly string[];
revoke: readonly string[];
}): Promise<HubRelationshipStatus> {
if (!this.record || this.record.state !== "active") {
throw new Error("This daemon is not connected to a Hub");
}
const grant = parseDaemonPermissions(input.grant);
const revoke = parseDaemonPermissions(input.revoke);
const permissions = parseDaemonPermissions([
...this.record.relationship.permissions.filter((permission) => !revoke.includes(permission)),
...grant,
]);
const result = await this.options.remote.updatePermissions({
daemonId: this.record.relationship.daemonId,
hubOrigin: this.record.relationship.hubOrigin,
credential: this.record.credential.secret,
permissions,
});
if (!samePermissions(result.permissions, permissions)) {
throw new Error("Hub permission response did not match the local grant");
}
this.record = {
...this.record,
relationship: { ...this.record.relationship, permissions },
};
this.persist(this.record);
this.options.updateAttachedPermissions(hubPrincipalId(this.record), permissions);
return this.status();
}
async disconnect(input: {
force: boolean;
}): Promise<{ status: HubRelationshipStatus; warning?: string }> {
@@ -299,7 +368,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
return { status: this.status() };
}
const disconnecting: DisconnectingRecord = {
version: 1,
version: 2,
state: "disconnecting",
relationship: this.record.relationship,
credential: this.record.credential,
@@ -345,7 +414,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
serverId: pending.identity.serverId,
daemonPublicKey: pending.identity.daemonPublicKey,
credentialVerifier: verifier,
scopes: pending.relationship.scopes,
permissions: pending.relationship.permissions,
});
const settled = request.then(
() => undefined,
@@ -357,12 +426,12 @@ export class HubRelationshipController implements HubRelationshipManagement {
if (enrollmentGeneration !== this.enrollmentGeneration) return;
if (
enrollment.daemonId !== pending.relationship.daemonId ||
!enrollment.scopes.includes(HUB_EXECUTION_SCOPE)
!samePermissions(enrollment.permissions, pending.relationship.permissions)
) {
throw new Error("Hub enrollment response did not match the pending relationship");
}
const active: ActiveRecord = {
version: 1,
version: 2,
state: "active",
relationship: pending.relationship,
credential: pending.credential,
@@ -390,7 +459,8 @@ export class HubRelationshipController implements HubRelationshipManagement {
const generation = ++this.generation;
this.state = reconnecting ? "reconnecting" : "connecting";
const events: HubSocketEvents = {
connected: (socket) => this.socketConnected(generation, record, socket),
connected: (socket, sessionProtocol) =>
this.socketConnected(generation, record, socket, sessionProtocol),
rejected: (statusCode) => this.socketRejected(generation, statusCode),
closed: (code) => this.socketClosed(generation, record, code),
failed: (error) => this.socketFailed(generation, record, error),
@@ -405,7 +475,12 @@ export class HubRelationshipController implements HubRelationshipManagement {
);
}
private socketConnected(generation: number, record: ActiveRecord, socket: WebSocketLike): void {
private socketConnected(
generation: number,
record: ActiveRecord,
socket: WebSocketLike,
sessionProtocol: "legacy" | "session-v1",
): void {
if (generation !== this.generation) {
socket.close();
return;
@@ -416,8 +491,10 @@ export class HubRelationshipController implements HubRelationshipManagement {
this.lastError = null;
void this.options.attachSocket(socket, {
daemonId: record.relationship.daemonId,
scopes: record.relationship.scopes,
principalId: hubPrincipalId(record),
permissions: record.relationship.permissions,
agents: this.executionAgentsFor(record.relationship.daemonId),
sessionProtocol,
});
}
@@ -487,13 +564,13 @@ export class HubRelationshipController implements HubRelationshipManagement {
this.cancelLifecycle();
if (!this.record) return;
const revoked: RevokedRecord = {
version: 1,
version: 2,
state: "revoked",
relationship: {
daemonId: this.record.relationship.daemonId,
hubOrigin: this.record.relationship.hubOrigin,
createdAt: this.record.relationship.createdAt,
scopes: this.record.relationship.scopes,
permissions: this.record.relationship.permissions,
},
transport: "transport" in this.record ? this.record.transport : undefined,
reason,
@@ -536,7 +613,9 @@ export class HubRelationshipController implements HubRelationshipManagement {
if (!existsSync(this.filePath)) return null;
let record: HubRelationshipRecord;
try {
record = RecordSchema.parse(JSON.parse(readFileSync(this.filePath, "utf8")));
const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
record = RecordSchema.parse(raw);
if (isLegacyRecord(raw)) this.persist(record);
} catch (error) {
const quarantinePath = path.join(
path.dirname(this.filePath),
@@ -554,3 +633,38 @@ export class HubRelationshipController implements HubRelationshipManagement {
return record;
}
}
function hubPrincipalId(record: ActiveRecord): string {
const credentialFingerprint = createHash("sha256")
.update(record.credential.secret)
.digest("base64url");
return `hub:${record.relationship.daemonId}:${credentialFingerprint}`;
}
function samePermissions(actual: readonly string[], expected: readonly string[]): boolean {
return actual.length === expected.length && expected.every((scope) => actual.includes(scope));
}
function isLegacyRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && Reflect.get(value, "version") === 1;
}
function migrateLegacyRecord(value: unknown): unknown {
if (!isLegacyRecord(value)) return value;
const relationship = Reflect.get(value, "relationship");
if (typeof relationship !== "object" || relationship === null) return value;
const scopes = Reflect.get(relationship, "scopes");
if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) return value;
const { scopes: _legacyScopes, ...relationshipWithoutScopes } = relationship as Record<
string,
unknown
>;
return {
...(value as Record<string, unknown>),
version: 2,
relationship: {
...relationshipWithoutScopes,
permissions: permissionsForLegacyHubScopes(scopes),
},
};
}
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, expect, test } from "vitest";
import { WebSocket } from "ws";
import { WebSocket, WebSocketServer } from "ws";
import type { HubExecutionAgents } from "./daemon-executions.js";
import {
HubRelationshipController,
@@ -51,6 +51,87 @@ test("transient revocation failures remain retryable", async () => {
).rejects.toThrow("Hub revocation failed (503)");
});
test("permission updates use the daemon credential and preserve semantic permissions", async () => {
let observed: { method?: string; authorization?: string; body?: unknown } = {};
const server = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
observed = {
method: request.method,
authorization: request.headers.authorization,
body: JSON.parse(body),
};
response
.writeHead(200, { "content-type": "application/json" })
.end(JSON.stringify({ permissions: ["hub.execute"] }));
});
openServers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address() as AddressInfo;
const hubOrigin = `http://127.0.0.1:${address.port}`;
const result = await new DirectHubRelationshipRemote().updatePermissions({
daemonId: "daemon-1",
hubOrigin,
credential: "daemon-secret",
permissions: ["hub.execute"],
});
expect(result).toEqual({ permissions: ["hub.execute"] });
expect(observed).toEqual({
method: "PATCH",
authorization: "Bearer daemon-secret",
body: { permissions: ["hub.execute"] },
});
});
test.each([
{ acknowledge: false, expected: "legacy" as const },
{ acknowledge: true, expected: "session-v1" as const },
])(
"negotiates $expected Hub session startup during WebSocket upgrade",
async ({ acknowledge, expected }) => {
const server = createServer();
const webSockets = new WebSocketServer({ noServer: true });
if (acknowledge) {
webSockets.on("headers", (headers) => headers.push("x-paseo-session-protocol: 1"));
}
let offeredProtocol: string | string[] | undefined;
server.on("upgrade", (request, socket, head) => {
offeredProtocol = request.headers["x-paseo-session-protocol"];
webSockets.handleUpgrade(request, socket, head, () => undefined);
});
openServers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address() as AddressInfo;
const negotiated = deferred<"legacy" | "session-v1">();
const socket = new DirectHubRelationshipRemote().openSocket(
{
daemonId: "daemon-1",
credential: "credential",
webSocketUrl: `ws://127.0.0.1:${address.port}/daemon`,
},
{
connected: (_connected, protocol) => negotiated.resolve(protocol),
rejected: () => undefined,
closed: () => undefined,
failed: () => undefined,
},
);
await expect(negotiated.promise).resolves.toBe(expected);
expect(offeredProtocol).toBe("1");
socket.close();
webSockets.close();
},
);
test.each([408, 429])("transient enrollment status %s remains retryable", async (status) => {
const hubOrigin = await startHubReturning(status);
const remote = new DirectHubRelationshipRemote();
@@ -65,7 +146,7 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
})
.catch((caught: unknown) => caught);
@@ -91,7 +172,7 @@ test("enrollment rejects a transport URL that cannot open a WebSocket", async ()
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
}),
).rejects.toThrow("Hub WebSocket URL must use ws or wss");
expect(enrolledHostname).toBe("test-daemon.local");
@@ -111,7 +192,7 @@ test("enrollment rejects a WebSocket URL with a fragment", async () => {
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
}),
).rejects.toThrow("Hub WebSocket URL cannot include a fragment");
});
@@ -130,7 +211,7 @@ test("enrollment rejects a WebSocket outside the enrolled Hub authority", async
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
}),
).rejects.toThrow("Hub WebSocket URL must match the Hub origin");
});
@@ -150,7 +231,7 @@ test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (oper
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
})
: remote.revoke({
daemonId: "daemon-1",
@@ -311,7 +392,7 @@ async function startEnrollmentHub(
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
webSocketUrl,
}),
);
@@ -402,7 +483,7 @@ class UpgradeRejectingHub {
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
webSocketUrl: this.webSocketUrl,
}),
);
@@ -544,9 +625,14 @@ async function connectController(
clock,
retryPolicy: clock,
attachSocket: async () => undefined,
updateAttachedPermissions: () => undefined,
createExecutionAgents: () => unusedExecutionAgents,
});
await controller.connect({ hubUrl: hub.origin, token: "enrollment-token" });
await controller.connect({
hubUrl: hub.origin,
token: "enrollment-token",
permissions: ["hub.execute"],
});
return controller;
}
@@ -11,15 +11,22 @@ export interface HubEnrollment {
serverId: string;
daemonPublicKey: string;
credentialVerifier: string;
scopes: string[];
permissions: string[];
}
export interface HubEnrollmentResult {
daemonId: string;
scopes: string[];
permissions: string[];
webSocketUrl: string;
}
export interface HubPermissionUpdate {
daemonId: string;
hubOrigin: string;
credential: string;
permissions: string[];
}
export interface HubRevocation {
daemonId: string;
hubOrigin: string;
@@ -33,7 +40,7 @@ export interface HubSocketCredentials {
}
export interface HubSocketEvents {
connected(socket: WebSocketLike): void;
connected(socket: WebSocketLike, sessionProtocol: "legacy" | "session-v1"): void;
rejected(statusCode: 401 | 403): void;
closed(code: number): void;
failed(error: Error): void;
@@ -45,6 +52,7 @@ export interface HubSocketConnection {
export interface HubRelationshipRemote {
enroll(input: HubEnrollment): Promise<HubEnrollmentResult>;
updatePermissions(input: HubPermissionUpdate): Promise<{ permissions: string[] }>;
revoke(input: HubRevocation): Promise<void>;
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection;
}
@@ -58,7 +66,7 @@ export class HubEnrollmentRejectedError extends Error {
const EnrollmentResultSchema = z.object({
daemonId: z.string(),
scopes: z.array(z.string()),
permissions: z.array(z.string()),
webSocketUrl: z
.string()
.url()
@@ -101,7 +109,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
serverId: input.serverId,
daemonPublicKey: input.daemonPublicKey,
credentialVerifier: input.credentialVerifier,
scopes: input.scopes,
permissions: input.permissions,
}),
signal,
});
@@ -117,6 +125,25 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
});
}
async updatePermissions(input: HubPermissionUpdate): Promise<{ permissions: string[] }> {
return this.withRequestTimeout(async (signal) => {
const response = await fetch(
`${input.hubOrigin}/api/daemons/${encodeURIComponent(input.daemonId)}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
authorization: `Bearer ${input.credential}`,
},
body: JSON.stringify({ permissions: input.permissions }),
signal,
},
);
if (!response.ok) throw new Error(`Hub permission update failed (${response.status})`);
return z.object({ permissions: z.array(z.string()) }).parse(await response.json());
});
}
async revoke(input: HubRevocation): Promise<void> {
await this.withRequestTimeout(async (signal) => {
const response = await fetch(
@@ -134,16 +161,23 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
}
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
let sessionProtocol: "legacy" | "session-v1" = "legacy";
const socket = new WebSocket(input.webSocketUrl, {
handshakeTimeout: this.requestTimeoutMs,
headers: {
authorization: `Bearer ${input.credential}`,
"x-paseo-daemon-id": input.daemonId,
"x-paseo-session-protocol": "1",
},
});
let settled = false;
socket.once("upgrade", (response) => {
if (response.headers["x-paseo-session-protocol"] === "1") {
sessionProtocol = "session-v1";
}
});
socket.once("open", () => {
if (!settled) events.connected(socket as WebSocketLike);
if (!settled) events.connected(socket as WebSocketLike, sessionProtocol);
});
socket.once("unexpected-response", (_request, response) => {
if (settled) {
@@ -85,7 +85,7 @@ interface PersistedRelationship {
daemonId: string;
idempotencyKey?: string;
hubOrigin: string;
scopes: string[];
permissions: string[];
};
credential?: { secret: string };
enrollment?: { token: string };
@@ -296,7 +296,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
private enrollmentObserved = deferred<void>();
private socketObserved = deferred<void>();
private enrollmentRejection: 401 | 403 | null = null;
private enrollmentScopes = ["hub.execution.*"];
private enrollmentPermissions: string[] | null = null;
private revokeFailures = 0;
private revocationGate: Deferred<void> | null = null;
private readonly relationships = new Set<string>();
@@ -313,13 +313,13 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
this.enrollmentRejection = statusCode;
}
returnEnrollmentScopes(scopes: string[]): void {
this.enrollmentScopes = scopes.slice();
returnEnrollmentPermissions(permissions: string[]): void {
this.enrollmentPermissions = permissions.slice();
}
async enroll(input: HubEnrollment): Promise<HubEnrollmentResult> {
this.enrollmentSnapshots.push(this.captureRelationship());
this.enrollments.push({ ...input, scopes: input.scopes.slice() });
this.enrollments.push({ ...input, permissions: input.permissions.slice() });
this.relationships.add(input.idempotencyKey);
this.enrollmentObserved.resolve();
if (this.enrollmentRejection) {
@@ -387,6 +387,15 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
}
}
async updatePermissions(input: {
daemonId: string;
hubOrigin: string;
credential: string;
permissions: string[];
}): Promise<{ permissions: string[] }> {
return { permissions: input.permissions.slice() };
}
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
this.socketSnapshots.push(this.captureRelationship());
const attempt = { input: { ...input }, events, socket: new MemoryHubSocket() };
@@ -410,7 +419,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
private enrollmentResult(input: HubEnrollment): HubEnrollmentResult {
return {
daemonId: input.daemonId,
scopes: this.enrollmentScopes.slice(),
permissions: this.enrollmentPermissions?.slice() ?? input.permissions.slice(),
webSocketUrl: SOCKET_URL,
};
}
@@ -505,9 +514,16 @@ export class HubRelationshipHarness {
this.remote.holdEnrollment();
}
beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN): CliProcess {
beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN, execute = true): CliProcess {
return {
result: this.runCli(["hub", "connect", hubUrl, "--api-key", `hub-contract-api-key:${token}`]),
result: this.runCli([
"hub",
"connect",
hubUrl,
"--api-key",
`hub-contract-api-key:${token}`,
...(execute ? ["--permission", "hub.execute"] : []),
]),
};
}
@@ -519,6 +535,14 @@ export class HubRelationshipHarness {
return this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]);
}
async grantPermission(permission: string): Promise<Record<string, unknown>> {
return this.runCli(["hub", "permissions", "grant", permission]);
}
async revokePermission(permission: string): Promise<Record<string, unknown>> {
return this.runCli(["hub", "permissions", "revoke", permission]);
}
beginDisconnect(force = false): CliProcess {
return { result: this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]) };
}
@@ -590,6 +614,7 @@ export class HubRelationshipHarness {
requestId: "browser-hub-connect",
hubUrl: HUB_ORIGIN,
token: "browser-token",
permissions: [],
},
}),
);
@@ -606,8 +631,8 @@ export class HubRelationshipHarness {
this.remote.completeEnrollment();
}
returnEnrollmentScopes(scopes: string[]): void {
this.remote.returnEnrollmentScopes(scopes);
returnEnrollmentPermissions(permissions: string[]): void {
this.remote.returnEnrollmentPermissions(permissions);
}
loseEnrollmentResponse(): void {
@@ -652,7 +677,18 @@ export class HubRelationshipHarness {
connectLatestSocket(): void {
const socket = this.latestSocket();
socket.events.connected(socket.socket);
socket.events.connected(socket.socket, "session-v1");
socket.socket.receiveEnvelope({
type: "hello",
clientId: `hub:${socket.input.daemonId}`,
clientType: "hub",
protocolVersion: 1,
});
}
connectLatestLegacySocket(): void {
const socket = this.latestSocket();
socket.events.connected(socket.socket, "legacy");
}
rejectLatestSocket(statusCode: 401 | 403): void {
@@ -676,7 +712,13 @@ export class HubRelationshipHarness {
connectSocket(index: number): void {
const socket = this.remote.sockets[index];
if (!socket) throw new Error(`Socket ${index} does not exist`);
socket.events.connected(socket.socket);
socket.events.connected(socket.socket, "session-v1");
socket.socket.receiveEnvelope({
type: "hello",
clientId: `hub:${socket.input.daemonId}`,
clientType: "hub",
protocolVersion: 1,
});
}
closeSocket(index: number, code: number): void {
@@ -1143,6 +1185,16 @@ export class HubRelationshipHarness {
);
}
serverInfoPermissions(): string[][] {
return this.remote.sockets.flatMap(({ socket }) =>
socket.sent.flatMap((message) =>
message.type === "status" && message.payload.status === "server_info"
? [message.payload.permissions ?? []]
: [],
),
);
}
probeTrustedHello(): number | null {
const socket = this.latestSocket().socket;
socket.receiveEnvelope({
@@ -1234,7 +1286,10 @@ export class HubRelationshipHarness {
}
enrollmentAttempts(): HubEnrollment[] {
return this.remote.enrollments.map((input) => ({ ...input, scopes: input.scopes.slice() }));
return this.remote.enrollments.map((input) => ({
...input,
permissions: input.permissions.slice(),
}));
}
enrollmentInvocation(index = 0): RelationshipInvocationSnapshot {
+31
View File
@@ -1911,6 +1911,33 @@ export class Session {
this.authorization.replacePermissions(permissions);
}
public getPermissions(): DaemonPermission[] {
return this.authorization.listPermissions();
}
public allowsInbound(message: SessionInboundMessage): boolean {
return this.authorization.allowsInbound(message);
}
public allowsPermission(permission: DaemonPermission): boolean {
return this.authorization.allowsPermission(permission);
}
public subscribesToAgent(agent: ManagedAgent): Promise<boolean> {
return this.agentUpdates.includesLiveAgent(agent);
}
public subscribesToTerminalDirectory(input: {
cwd: string;
workspaceId?: string;
}): Promise<boolean> {
return this.terminalController.hasDirectorySubscription(input);
}
public publish(message: SessionOutboundMessage): void {
this.emit(message);
}
private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
const promise =
this.dispatchVoiceAndControlMessage(msg) ??
@@ -2341,6 +2368,7 @@ export class Session {
case "hub.management.daemon.connect.request":
case "hub.management.daemon.get_status.request":
case "hub.management.daemon.disconnect.request":
case "hub.management.daemon.permissions.update.request":
return this.daemonSession.handleHubRelationshipRequest(msg);
case "diagnostics.request":
return this.daemonSession.handleDiagnosticsRequest(msg);
@@ -2635,6 +2663,9 @@ export class Session {
}
public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise<void> {
if (!this.authorization.allowsPermission("workspace.write")) {
return;
}
if (binaryFrame.kind === "file_transfer") {
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame);
return;
@@ -617,6 +617,21 @@ describe("bootstrap buffering", () => {
});
describe("subscription lifecycle", () => {
test("attention eligibility follows the active directory subscription filter", async () => {
const h = buildHarness();
h.register(makeAgentPayload({ id: "matching", workspaceId: "ws-1", labels: { team: "a" } }));
h.register(makeAgentPayload({ id: "excluded", workspaceId: "ws-2", labels: { team: "b" } }));
expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(false);
h.service.beginSubscription({ subscriptionId: "sub", filter: { labels: { team: "a" } } });
expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(true);
expect(await h.service.includesLiveAgent(h.managed("excluded"))).toBe(false);
h.service.clearSubscription("sub");
expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(false);
});
test("flushBootstrapped is a no-op for a stale subscription id", async () => {
const h = buildHarness();
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
@@ -49,6 +49,7 @@ export interface AgentUpdatesService {
): void;
clearSubscription(subscriptionId: string): void;
hasSubscription(): boolean;
includesLiveAgent(agent: ManagedAgent): Promise<boolean>;
forwardLiveAgent(agent: ManagedAgent): Promise<void>;
emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload>;
removeAgent(agentId: string): Promise<void>;
@@ -243,6 +244,28 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
return subscription !== null;
}
async function includesLiveAgent(agent: ManagedAgent): Promise<boolean> {
const activeSubscription = subscription;
if (!activeSubscription) return false;
const payload = await deps.enrichAgentPayload(toAgentPayload(agent));
if (subscription !== activeSubscription || !deps.isProviderVisibleToClient(payload.provider)) {
return false;
}
const project = payload.workspaceId
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
return (
subscription === activeSubscription &&
project !== null &&
matchesAgentUpdatesFilter({
agent: payload,
project,
filter: activeSubscription.filter,
})
);
}
async function emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload> {
const payload = deps.buildStoredAgentPayload(record);
const sub = subscription;
@@ -391,6 +414,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
flushBootstrapped,
clearSubscription,
hasSubscription,
includesLiveAgent,
forwardLiveAgent,
emitStoredRecord,
removeAgent,
@@ -110,7 +110,8 @@ export class DaemonSession {
type:
| "hub.management.daemon.connect.request"
| "hub.management.daemon.get_status.request"
| "hub.management.daemon.disconnect.request";
| "hub.management.daemon.disconnect.request"
| "hub.management.daemon.permissions.update.request";
}
>,
): Promise<void> {
@@ -120,6 +121,7 @@ export class DaemonSession {
const status = await this.hubRelationships.connect({
hubUrl: msg.hubUrl,
token: msg.token,
permissions: msg.permissions,
});
this.host.emit({
type: "hub.management.daemon.connect.response",
@@ -127,6 +129,17 @@ export class DaemonSession {
});
return;
}
if (msg.type === "hub.management.daemon.permissions.update.request") {
const status = await this.hubRelationships.updatePermissions({
grant: msg.grant,
revoke: msg.revoke,
});
this.host.emit({
type: "hub.management.daemon.permissions.update.response",
payload: { requestId: msg.requestId, status },
});
return;
}
if (msg.type === "hub.management.daemon.disconnect.request") {
const result = await this.hubRelationships.disconnect({ force: msg.force ?? false });
this.host.emit({
@@ -169,11 +169,13 @@ function createSessionWithActivity(
appVisible: boolean;
appVisibilityChangedAt?: Date;
} | null,
subscribed = true,
) {
return {
getClientActivity: vi.fn(() => activity),
supports: () => false,
supportsForSource: () => false,
subscribesToAgent: vi.fn(async () => subscribed),
};
}
@@ -186,11 +188,12 @@ function connectClient(
appVisible: boolean;
appVisibilityChangedAt?: Date;
} | null,
options: { subscribed?: boolean } = {},
) {
const ws = createOpenSocket();
asInternals<WebSocketServerInternals>(server).sessions.set(ws, {
kind: "trusted",
session: createSessionWithActivity(activity),
session: createSessionWithActivity(activity, options.subscribed ?? true),
clientId: "client-test",
appVersion: null,
connectionLogger: createLogger(),
@@ -216,6 +219,30 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
vi.clearAllMocks();
});
it("does not emit attention or include presence without an agent-directory subscription", async () => {
const { server, pushNotifications } = createServer();
const now = new Date();
const unsubscribed = connectClient(
server,
{
deviceType: "web",
appVisible: true,
focusedAgentId: "agent-1",
lastActivityAt: now,
},
{ subscribed: false },
);
await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
agentId: "agent-1",
provider: "claude",
reason: "finished",
});
expect(unsubscribed.send).not.toHaveBeenCalled();
expect(pushNotifications.sent).toHaveLength(1);
});
it("uses assistant preview text for push notifications with markdown removed", async () => {
const getLastAssistantMessage = vi.fn(
async () => "**Done**. Updated `README.md` and [link](https://example.com).",
@@ -56,6 +56,13 @@ const sessionMock = vi.hoisted(() => {
clearAgentTimelineSubscription = vi.fn();
getClientActivity = vi.fn(() => null);
getSessionId = vi.fn(() => "mock-session-id");
getPermissions = vi.fn(() => this.args.permissions as string[]);
allowsInbound = vi.fn(() => true);
allowsPermission = vi.fn(() => true);
publish = vi.fn((message: unknown) => {
const onMessage = this.args.onMessage as ((message: unknown) => void) | undefined;
onMessage?.(message);
});
resetPeakInflight = vi.fn(() => {});
getRuntimeMetrics = vi.fn(() => ({
checkoutDiffTargetCount: 0,
@@ -96,7 +103,7 @@ vi.mock("./push/index.js", () => ({
import { z } from "zod";
import { VoiceAssistantWebSocketServer } from "./websocket-server";
import { parseServerInfoStatusPayload } from "./messages.js";
import { DAEMON_PERMISSIONS, parseServerInfoStatusPayload } from "./messages.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
interface WebSocketServerInternals {
@@ -676,6 +683,28 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("isolates resumable sessions by principal while sharing hello bootstrap", async () => {
const server = createServer();
const clientId = "shared-client-id";
const ownerSocket = new MockSocket();
const hubSocket = new MockSocket();
const ownerInfo = await attachRelayAndHello({ server, socket: ownerSocket, clientId });
await server.attachExternalSocket(
hubSocket,
{ transport: "hub", hubDaemonId: "daemon-1" },
{ principalId: "hub:daemon-1", permissions: ["hub.execute"] },
);
hubSocket.emit("message", JSON.stringify(createHelloMessage(clientId)));
const hubEnvelope = parseSentEnvelope(hubSocket.sent[0]);
const hubInfo = parseServerInfoStatusPayload(hubEnvelope.message?.payload);
expect(sessionMock.instances).toHaveLength(2);
expect(ownerInfo.permissions).toEqual(DAEMON_PERMISSIONS);
expect(hubInfo?.permissions).toEqual(["hub.execute"]);
await server.close();
});
test("rejects session messages before hello", async () => {
const server = createServer();
const socket = new MockSocket();
@@ -982,6 +1011,7 @@ describe("relay external socket reconnect behavior", () => {
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
expect(serverInfo.permissions).toEqual(DAEMON_PERMISSIONS);
await server.close();
});
@@ -192,12 +192,13 @@ function createOpenSocket() {
};
}
function connectClient(server: VoiceAssistantWebSocketServer) {
function connectClient(server: VoiceAssistantWebSocketServer, subscribed = true) {
const ws = createOpenSocket();
asInternals<{ sessions: Map<unknown, unknown> }>(server).sessions.set(ws, {
kind: "trusted",
session: {
getClientActivity: vi.fn(() => null),
subscribesToTerminalDirectory: vi.fn(async () => subscribed),
},
clientId: "client-test",
appVersion: null,
@@ -274,6 +275,25 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
vi.clearAllMocks();
});
it("does not emit attention without a matching terminal-directory subscription", async () => {
const { manager, emit } = createTerminalManager();
const { server, pushNotifications } = createServer(manager);
const ws = connectClient(server, false);
emit(
transition({
previousState: "working",
previousChangedAt: 1000,
state: "idle",
changedAt: 11001,
}),
);
await flushAsync();
expectNoTerminalAttentionMessage(ws);
expect(pushNotifications.sent).toHaveLength(1);
});
it("broadcasts terminal_attention_required after working -> idle", async () => {
const { manager, emit } = createTerminalManager();
const { server } = createServer(manager);
+132 -157
View File
@@ -98,11 +98,7 @@ import {
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import {
OWNER_PERMISSIONS,
permissionsForLegacyHubScopes,
type DaemonPermission,
} from "./authorization/index.js";
import { OWNER_PERMISSIONS, type DaemonPermission } from "./authorization/index.js";
import type { WorkspaceLabelService } from "./workspace-labels/index.js";
import {
APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS,
@@ -117,20 +113,28 @@ import {
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
export interface ExternalSocketMetadata {
transport: "relay";
transport: "relay" | "hub";
externalSessionKey?: string;
relayConnectionId?: string;
hubDaemonId?: string;
}
export interface SessionAdmission {
principalId: string;
permissions: readonly DaemonPermission[];
hubExecutionAgents?: HubExecutionAgents;
}
interface PendingConnection {
connectionLogger: pino.Logger;
helloTimeout: ReturnType<typeof setTimeout> | null;
identity: WebSocketConnectionIdentity;
admission: SessionAdmission;
}
interface WebSocketConnectionIdentity {
connectionId: string;
transport: "direct" | "relay";
transport: "direct" | "relay" | "hub";
peer: "loopback" | "local_ipc" | "external";
browserOrigin: boolean;
host?: string;
@@ -138,6 +142,7 @@ interface WebSocketConnectionIdentity {
userAgent?: string;
remoteAddress?: string;
relayConnectionId?: string;
hubDaemonId?: string;
clientId?: string;
sessionId?: string;
appVersion?: string;
@@ -433,9 +438,10 @@ export interface WebSocketLike {
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
}
interface TrustedSessionConnectionBase {
kind: "trusted";
interface SessionConnectionBase {
session: Session;
principalId: string;
sessionKey: string;
clientId: string;
appVersion: string | null;
clientCapabilities: Record<string, unknown> | null;
@@ -443,37 +449,17 @@ interface TrustedSessionConnectionBase {
sockets: Set<WebSocketLike>;
}
interface ReconnectableSessionConnection extends TrustedSessionConnectionBase {
interface ReconnectableSessionConnection extends SessionConnectionBase {
lifecycle: "reconnectable";
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
}
interface PluginSessionConnection extends TrustedSessionConnectionBase {
interface PluginSessionConnection extends SessionConnectionBase {
lifecycle: "ephemeral-plugin";
pluginId: string;
}
type TrustedSessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
interface HubConnection {
kind: "hub";
session: Session;
daemonId: string;
connectionLogger: pino.Logger;
socket: WebSocketLike;
}
type SessionConnection = TrustedSessionConnection | HubConnection;
type TrustedLifecycleKey =
| "clientId"
| "appVersion"
| "clientCapabilities"
| "sockets"
| "externalDisconnectCleanupTimeout";
type HubLifecycleOverlap = Extract<keyof HubConnection, TrustedLifecycleKey>;
const HUB_HAS_NO_TRUSTED_LIFECYCLE_STATE: HubLifecycleOverlap extends never ? true : never = true;
void HUB_HAS_NO_TRUSTED_LIFECYCLE_STATE;
type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
interface BrowserToolsRegistration {
capabilitySignature: string;
@@ -511,6 +497,10 @@ const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
const WS_CLOSE_SERVER_SHUTDOWN = 1001;
const WS_PROTOCOL_VERSION = 1;
const WS_RUNTIME_METRICS_FLUSH_MS = 30_000;
const OWNER_SESSION_ADMISSION: SessionAdmission = {
principalId: "owner",
permissions: OWNER_PERMISSIONS,
};
export class MissingDaemonVersionError extends Error {
constructor() {
@@ -929,26 +919,27 @@ export class VoiceAssistantWebSocketServer {
}
public broadcast(message: WSOutboundMessage): void {
const trustedSockets = [...this.sessions]
.filter(([, connection]) => connection.kind === "trusted")
.map(([ws]) => ws);
this.sendMessageToSockets(trustedSockets, message);
if (message.type === "session") {
for (const connection of new Set(this.sessions.values())) {
connection.session.publish(message.message);
}
return;
}
this.sendMessageToSockets(this.sessions.keys(), message);
}
public listTrustedSessions(): Session[] {
public listSessions(): Session[] {
return Array.from(
new Set(
[...this.sessions.values(), ...this.externalSessionsByKey.values()]
.filter(
(connection): connection is TrustedSessionConnection => connection.kind === "trusted",
)
.map((connection) => connection.session),
[...this.sessions.values(), ...this.externalSessionsByKey.values()].map(
(connection) => connection.session,
),
),
);
}
public publishProjectUpdate(update: ProjectUpdate): void {
for (const session of this.listTrustedSessions()) {
for (const session of this.listSessions()) {
void session
.emitProjectUpdate(update)
.catch((error) => this.logger.warn({ err: error }, "Failed to publish project update"));
@@ -971,11 +962,13 @@ export class VoiceAssistantWebSocketServer {
public async attachExternalSocket(
ws: WebSocketLike,
metadata?: ExternalSocketMetadata,
admission: SessionAdmission = OWNER_SESSION_ADMISSION,
initialHello?: WSHelloMessage,
): Promise<void> {
if (metadata?.transport === "relay") {
this.incrementRuntimeCounter("relayExternalSocketAttached");
}
await this.attachSocket(ws, undefined, metadata);
await this.attachSocket(ws, undefined, metadata, false, admission, initialHello);
}
public async attachPluginSocket(
@@ -1001,42 +994,21 @@ export class VoiceAssistantWebSocketServer {
return { closed };
}
public async attachHubSocket(
ws: WebSocketLike,
options: {
daemonId: string;
scopes: readonly string[];
agents: HubExecutionAgents;
},
): Promise<void> {
if (this.connectionLifecycle !== "accepting") {
ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down");
return;
public updatePrincipalPermissions(
principalId: string,
permissions: readonly DaemonPermission[],
): void {
for (const pending of this.pendingConnections.values()) {
if (pending.admission.principalId === principalId) {
pending.admission = { ...pending.admission, permissions };
}
}
for (const connection of new Set(this.externalSessionsByKey.values())) {
if (connection.principalId === principalId) {
connection.session.setPermissions(permissions);
this.syncBrowserToolsClientRegistration(connection);
}
}
const connectionLogger = this.logger.child({
connectionKind: "hub",
daemonId: options.daemonId,
});
const session = this.createSocketSession({
clientId: `hub:${options.daemonId}`,
appVersion: null,
clientCapabilities: null,
permissions: permissionsForLegacyHubScopes(options.scopes),
connectionLogger,
onMessage: (message) => this.sendToClient(ws, wrapSessionMessage(message)),
hubExecutionAgents: options.agents,
});
const connection: HubConnection = {
kind: "hub",
session,
daemonId: options.daemonId,
connectionLogger,
socket: ws,
};
this.sessions.set(ws, connection);
this.bindSocketHandlers(ws);
connectionLogger.info("Hub session attached");
}
public prepareForShutdown(): void {
@@ -1085,18 +1057,13 @@ export class VoiceAssistantWebSocketServer {
const cleanupPromises: Promise<void>[] = [];
for (const connection of uniqueConnections) {
if (
connection.kind === "trusted" &&
connection.lifecycle === "reconnectable" &&
connection.externalDisconnectCleanupTimeout
) {
if (connection.lifecycle === "reconnectable" && connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout);
connection.externalDisconnectCleanupTimeout = null;
}
cleanupPromises.push(Promise.resolve(connection.session.cleanup()));
const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket];
for (const ws of sockets) {
for (const ws of connection.sockets) {
cleanupPromises.push(
new Promise<void>((resolve) => {
// WebSocket.CLOSED = 3
@@ -1256,14 +1223,10 @@ export class VoiceAssistantWebSocketServer {
}
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket];
this.sendMessageToSockets(sockets, message);
this.sendMessageToSockets(connection.sockets, message);
}
private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void {
if (connection.kind !== "trusted") {
return;
}
for (const ws of connection.sockets) {
this.sendBinaryToClient(ws, frame);
}
@@ -1274,6 +1237,8 @@ export class VoiceAssistantWebSocketServer {
request?: unknown,
metadata?: ExternalSocketMetadata,
allowDuringStartup = false,
admission: SessionAdmission = OWNER_SESSION_ADMISSION,
initialHello?: WSHelloMessage,
): Promise<void> {
if (
this.connectionLifecycle === "stopping" ||
@@ -1296,6 +1261,7 @@ export class VoiceAssistantWebSocketServer {
connectionLogger,
helloTimeout: null,
identity,
admission,
};
const timeout = setTimeout(() => {
if (this.pendingConnections.get(ws) !== pending) {
@@ -1327,6 +1293,9 @@ export class VoiceAssistantWebSocketServer {
},
"Client connected; awaiting hello",
);
if (initialHello) {
this.handleHello({ ws, message: initialHello, pending });
}
}
private createSessionConnection(params: {
@@ -1336,15 +1305,17 @@ export class VoiceAssistantWebSocketServer {
clientCapabilities: Record<string, unknown> | null;
connectionLogger: pino.Logger;
lifecycle: { kind: "reconnectable" } | { kind: "ephemeral-plugin"; pluginId: string };
}): TrustedSessionConnection {
const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle } = params;
let connection: TrustedSessionConnection | null = null;
admission: SessionAdmission;
}): SessionConnection {
const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle, admission } =
params;
let connection: SessionConnection | null = null;
const session = this.createSocketSession({
clientId,
appVersion,
clientCapabilities,
permissions: OWNER_PERMISSIONS,
permissions: admission.permissions,
connectionLogger,
onMessage: (msg) => {
if (!connection) {
@@ -1389,12 +1360,14 @@ export class VoiceAssistantWebSocketServer {
onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent);
},
hubExecutionAgents: admission.hubExecutionAgents,
hubRelationships: this.hubRelationships ?? undefined,
});
const base: TrustedSessionConnectionBase = {
kind: "trusted",
const base: SessionConnectionBase = {
session,
principalId: admission.principalId,
sessionKey: sessionConnectionKey(admission.principalId, clientId),
clientId,
appVersion,
clientCapabilities,
@@ -1424,7 +1397,7 @@ export class VoiceAssistantWebSocketServer {
logger: options.connectionLogger.child({ module: "session" }),
onWorkspaceRecovered: async (workspace) => {
await Promise.all(
this.listTrustedSessions().map((activeSession) =>
this.listSessions().map((activeSession) =>
activeSession.refreshRecoveredWorkspaceForExternalMutation(workspace),
),
);
@@ -1564,7 +1537,8 @@ export class VoiceAssistantWebSocketServer {
if (message.appVersion) {
pending.identity.appVersion = message.appVersion;
}
const existing = pluginId ? undefined : this.externalSessionsByKey.get(clientId);
const sessionKey = sessionConnectionKey(pending.admission.principalId, clientId);
const existing = pluginId ? undefined : this.externalSessionsByKey.get(sessionKey);
if (existing) {
this.resumeSession({ ws, message, pending, existing });
return;
@@ -1579,14 +1553,15 @@ export class VoiceAssistantWebSocketServer {
clientCapabilities: message.capabilities ?? null,
connectionLogger,
lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" },
admission: pending.admission,
});
this.sessions.set(ws, connection);
if (connection.lifecycle === "reconnectable") {
this.externalSessionsByKey.set(clientId, connection);
this.externalSessionsByKey.set(sessionKey, connection);
}
pending.identity.sessionId = connection.session.getSessionId();
this.syncBrowserToolsClientRegistration(connection);
this.sendToClient(ws, this.createServerInfoMessage());
this.sendToClient(ws, this.createServerInfoMessage(connection.session));
connection.connectionLogger.info(
{
...toConnectionLogFields(pending.identity),
@@ -1630,7 +1605,7 @@ export class VoiceAssistantWebSocketServer {
this.sessions.set(ws, existing);
pending.identity.sessionId = existing.session.getSessionId();
this.syncBrowserToolsClientRegistration(existing);
this.sendToClient(ws, this.createServerInfoMessage());
this.sendToClient(ws, this.createServerInfoMessage(existing.session));
pending.connectionLogger.info(
{
...toConnectionLogFields(pending.identity),
@@ -1641,12 +1616,13 @@ export class VoiceAssistantWebSocketServer {
);
}
private buildServerInfoStatusPayload(): ServerInfoStatusPayload {
private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload {
return {
status: "server_info",
serverId: this.serverId,
hostname: getHostname(),
version: this.daemonVersion,
permissions: session.getPermissions(),
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
@@ -1785,12 +1761,12 @@ export class VoiceAssistantWebSocketServer {
};
}
private createServerInfoMessage(): WSOutboundMessage {
private createServerInfoMessage(session: Session): WSOutboundMessage {
return {
type: "session",
message: {
type: "status",
payload: this.buildServerInfoStatusPayload(),
payload: this.buildServerInfoStatusPayload(session),
},
};
}
@@ -1806,7 +1782,9 @@ export class VoiceAssistantWebSocketServer {
}
private broadcastCapabilitiesUpdate(): void {
this.broadcast(this.createServerInfoMessage());
for (const connection of new Set(this.sessions.values())) {
this.sendToConnection(connection, this.createServerInfoMessage(connection.session));
}
}
private broadcastDaemonConfigChanged(config: MutableDaemonConfig): void {
@@ -1892,22 +1870,12 @@ export class VoiceAssistantWebSocketServer {
}
this.sessions.delete(ws);
if (connection.kind === "hub") {
this.socketIdentities.delete(ws);
connection.connectionLogger.info(
{ code: details.code, reason: stringifyCloseReason(details.reason) },
"Hub session disconnected",
);
connection.session.cleanup();
this.finishPluginSocketCleanup(ws);
return;
}
connection.sockets.delete(ws);
connection.session.clearAgentTimelineSubscription(ws);
this.socketIdentities.delete(ws);
if (connection.sockets.size === 0) {
this.unregisterBrowserToolsClient(connection.clientId);
this.unregisterBrowserToolsClient(connection);
if (connection.lifecycle === "ephemeral-plugin") {
this.pluginSocketIds.delete(ws);
await this.cleanupConnection(connection, "Plugin session disconnected");
@@ -1965,7 +1933,7 @@ export class VoiceAssistantWebSocketServer {
}
private async cleanupConnection(
connection: TrustedSessionConnection,
connection: SessionConnection,
logMessage: string,
): Promise<void> {
this.incrementRuntimeCounter("sessionCleanup");
@@ -1980,12 +1948,12 @@ export class VoiceAssistantWebSocketServer {
}
connection.sockets.clear();
if (connection.lifecycle === "reconnectable") {
const existing = this.externalSessionsByKey.get(connection.clientId);
const existing = this.externalSessionsByKey.get(connection.sessionKey);
if (existing === connection) {
this.externalSessionsByKey.delete(connection.clientId);
this.externalSessionsByKey.delete(connection.sessionKey);
}
}
this.unregisterBrowserToolsClient(connection.clientId);
this.unregisterBrowserToolsClient(connection);
connection.connectionLogger.trace(
{ clientId: connection.clientId, totalSessions: this.sessions.size },
@@ -1994,45 +1962,51 @@ export class VoiceAssistantWebSocketServer {
await connection.session.cleanup();
}
private syncBrowserToolsClientRegistration(connection: TrustedSessionConnection): void {
private syncBrowserToolsClientRegistration(connection: SessionConnection): void {
if (!this.browserToolsBroker) {
return;
}
const registrationKey = connection.sessionKey;
if (!connection.session.allowsPermission("workspace.write")) {
this.unregisterBrowserToolsClient(registrationKey);
return;
}
const browserHostCapability = getBrowserHostCapability(connection.clientCapabilities);
if (!browserHostCapability) {
this.unregisterBrowserToolsClient(connection.clientId);
this.unregisterBrowserToolsClient(registrationKey);
return;
}
const capabilitySignature = JSON.stringify(browserHostCapability);
const existing = this.browserToolsRegistrations.get(connection.clientId);
const existing = this.browserToolsRegistrations.get(registrationKey);
if (existing?.capabilitySignature === capabilitySignature) {
return;
}
if (existing) {
this.browserToolsRegistrations.delete(connection.clientId);
this.browserToolsRegistrations.delete(registrationKey);
existing.unregister();
}
const unregister = this.browserToolsBroker.registerClient({
id: connection.clientId,
id: connection.principalId === "owner" ? connection.clientId : registrationKey,
hostKind: browserHostCapability.hostKind,
supportedCommands: browserHostCapability.supportedCommands,
sendBrowserAutomationRequest: (request) => {
this.sendToConnection(connection, wrapSessionMessage(request));
},
});
this.browserToolsRegistrations.set(connection.clientId, {
this.browserToolsRegistrations.set(registrationKey, {
capabilitySignature,
unregister,
});
}
private unregisterBrowserToolsClient(clientId: string): void {
const registration = this.browserToolsRegistrations.get(clientId);
private unregisterBrowserToolsClient(connection: SessionConnection | string): void {
const registrationKey = typeof connection === "string" ? connection : connection.sessionKey;
const registration = this.browserToolsRegistrations.get(registrationKey);
if (!registration) {
return;
}
this.browserToolsRegistrations.delete(clientId);
this.browserToolsRegistrations.delete(registrationKey);
registration.unregister();
}
@@ -2070,7 +2044,7 @@ export class VoiceAssistantWebSocketServer {
log.warn(
{
clientId: activeConnection?.kind === "trusted" ? activeConnection.clientId : undefined,
clientId: activeConnection?.clientId,
requestId: requestInfo?.requestId,
requestType: requestInfo?.requestType,
error: parsedMessage.error.message,
@@ -2135,11 +2109,6 @@ export class VoiceAssistantWebSocketServer {
}
return true;
}
if (activeConnection.kind === "hub") {
log.warn("Rejected binary frame on Hub session");
ws.close(WS_CLOSE_INVALID_HELLO, "Binary frames are not supported on Hub sessions");
return true;
}
void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame)).catch(
(error: unknown) => {
this.handleRawMessageError({
@@ -2288,10 +2257,8 @@ export class VoiceAssistantWebSocketServer {
let connectionFields: Record<string, unknown>;
if (identity) {
connectionFields = toConnectionLogFields(identity);
} else if (activeConnection.kind === "trusted") {
connectionFields = { clientId: activeConnection.clientId };
} else {
connectionFields = { daemonId: activeConnection.daemonId };
connectionFields = { clientId: activeConnection.clientId };
}
activeConnection.connectionLogger.warn(
{
@@ -2301,10 +2268,11 @@ export class VoiceAssistantWebSocketServer {
"ws_control_rpc_received",
);
}
if (
activeConnection.kind === "trusted" &&
message.message.type === "browser.automation.execute.response"
) {
if (message.message.type === "browser.automation.execute.response") {
if (!activeConnection.session.allowsInbound(message.message)) {
await activeConnection.session.handleMessage(message.message, ws);
return;
}
this.browserToolsBroker?.receiveResponse(message.message as BrowserAutomationExecuteResponse);
return;
}
@@ -2314,7 +2282,7 @@ export class VoiceAssistantWebSocketServer {
const durationMs = performance.now() - startMs;
this.recordRequestLatency(message.message.type, durationMs);
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS && activeConnection.kind === "trusted") {
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) {
activeConnection.connectionLogger.warn(
{
requestType: message.message.type,
@@ -2426,9 +2394,7 @@ export class VoiceAssistantWebSocketServer {
}
private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics {
const uniqueConnections = new Set<TrustedSessionConnection>(
this.externalSessionsByKey.values(),
);
const uniqueConnections = new Set<SessionConnection>(this.externalSessionsByKey.values());
let terminalDirectorySubscriptionCount = 0;
let terminalSubscriptionCount = 0;
let workspaceGitWatchedDirectoryCount = 0;
@@ -2537,15 +2503,17 @@ export class VoiceAssistantWebSocketServer {
provider: AgentProvider;
reason: "finished" | "error" | "permission";
}): Promise<void> {
const agent = this.agentManager.getAgent(params.agentId);
if (!agent?.workspaceId) {
return;
}
const clientEntries: Array<{
ws: WebSocketLike;
state: ClientPresenceState;
}> = [];
for (const [ws, connection] of this.sessions) {
if (connection.kind !== "trusted") {
continue;
}
if (!(await connection.session.subscribesToAgent(agent))) continue;
clientEntries.push({
ws,
state: this.getClientActivityState(connection.session),
@@ -2554,10 +2522,6 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state);
const nowMs = Date.now();
const agent = this.agentManager.getAgent(params.agentId);
if (!agent?.workspaceId) {
return;
}
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
const notification = buildAgentAttentionNotificationPayload({
reason: params.reason,
@@ -2632,7 +2596,12 @@ export class VoiceAssistantWebSocketServer {
}> = [];
for (const [ws, connection] of this.sessions) {
if (connection.kind !== "trusted") {
if (
!(await connection.session.subscribesToTerminalDirectory({
cwd: params.cwd,
...(params.workspaceId ? { workspaceId: params.workspaceId } : {}),
}))
) {
continue;
}
clientEntries.push({
@@ -2708,7 +2677,7 @@ function createWebSocketConnectionIdentity(
): WebSocketConnectionIdentity {
return {
connectionId: `conn_${randomUUID().replaceAll("-", "")}`,
transport: metadata?.transport === "relay" ? "relay" : "direct",
transport: metadata?.transport ?? "direct",
peer: resolveConnectionPeer(requestMetadata, metadata),
browserOrigin: requestMetadata.origin !== undefined,
...(requestMetadata.host ? { host: requestMetadata.host } : {}),
@@ -2716,9 +2685,14 @@ function createWebSocketConnectionIdentity(
...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}),
...(requestMetadata.remoteAddress ? { remoteAddress: requestMetadata.remoteAddress } : {}),
...(metadata?.relayConnectionId ? { relayConnectionId: metadata.relayConnectionId } : {}),
...(metadata?.hubDaemonId ? { hubDaemonId: metadata.hubDaemonId } : {}),
};
}
function sessionConnectionKey(principalId: string, clientId: string): string {
return JSON.stringify([principalId, clientId]);
}
function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record<string, string> {
return {
connectionId: identity.connectionId,
@@ -2729,6 +2703,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record<st
...(identity.userAgent ? { userAgent: identity.userAgent } : {}),
...(identity.remoteAddress ? { remoteAddress: identity.remoteAddress } : {}),
...(identity.relayConnectionId ? { relayConnectionId: identity.relayConnectionId } : {}),
...(identity.hubDaemonId ? { hubDaemonId: identity.hubDaemonId } : {}),
...(identity.clientId ? { clientId: identity.clientId } : {}),
...(identity.sessionId ? { sessionId: identity.sessionId } : {}),
...(identity.appVersion ? { appVersion: identity.appVersion } : {}),
@@ -2739,7 +2714,7 @@ function resolveConnectionPeer(
requestMetadata: SocketRequestMetadata,
metadata: ExternalSocketMetadata | undefined,
): WebSocketConnectionIdentity["peer"] {
if (metadata?.transport === "relay") return "external";
if (metadata !== undefined) return "external";
if (!requestMetadata.remoteAddress) return "local_ipc";
return isLoopbackAddress(requestMetadata.remoteAddress) ? "loopback" : "external";
}
@@ -617,11 +617,23 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-a" });
controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-b" });
await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-a" })).resolves.toBe(
true,
);
await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-b" })).resolves.toBe(
true,
);
await flushMicrotasks();
outboundMessages.length = 0;
// Tearing down workspace B must not drop workspace A's live subscription.
controller.dispatch({ type: "unsubscribe_terminals_request", cwd, workspaceId: "ws-b" });
await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-a" })).resolves.toBe(
true,
);
await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-b" })).resolves.toBe(
false,
);
changedListener?.({ cwd, terminals: [{ id: "a", name: "A", cwd, workspaceId: "ws-a" }] });
await flushMicrotasks();
@@ -178,6 +178,19 @@ export class TerminalSessionController {
};
}
async hasDirectorySubscription(input: { cwd: string; workspaceId?: string }): Promise<boolean> {
const workspaceRoots = await this.listTerminalWorkspaceRoots();
return Array.from(this.subscribedDirectories.values()).some((subscription) => {
if (
subscription.workspaceId !== undefined &&
subscription.workspaceId !== input.workspaceId
) {
return false;
}
return this.terminalBelongsToRoot(subscription.cwd, input.cwd, workspaceRoots);
});
}
dispatch(msg: SessionInboundMessage): Promise<void> | undefined {
if (!isTerminalMessage(msg)) {
return undefined;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.7.0-beta.1",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
+11
View File
@@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_488)">
<path d="M4.50391 0.900391L12.5537 0.958008L12.751 0.969727C13.7276 1.07414 14.515 1.88867 14.5244 2.89648L14.6201 12.0547C14.6256 12.582 14.4168 13.0759 14.042 13.4414L14.043 13.4424C13.6798 13.7974 13.1987 13.9929 12.6914 13.998C12.6218 14.2376 12.4926 14.4566 12.3135 14.6367C12.0251 14.9267 11.6376 15.084 11.2324 15.084H11.2207L3.02637 15.0254H3.02539C2.19993 15.0189 1.50689 14.3557 1.49805 13.5195L1.40039 4.2041C1.3964 3.80142 1.55224 3.41827 1.83691 3.13281C2.03404 2.93517 2.27743 2.80079 2.54102 2.73438C2.56372 2.25029 2.76513 1.79842 3.11426 1.45703V1.45605C3.50319 1.07657 4.00506 0.923809 4.46973 0.900391L4.48633 0.899414L4.50391 0.900391ZM5.17285 11.4004L12.0674 11.4697L11.9844 3.49707L5.10645 3.42871L5.17285 11.4004Z" fill="#1D1D1B" stroke="white" stroke-width="1.2"/>
<rect x="5" y="3.5" width="7" height="8" fill="white"/>
</g>
<defs>
<clipPath id="clip0_178_488">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+90 -10
View File
@@ -1,15 +1,95 @@
import { motion } from "framer-motion";
import { useCallback, useState } from "react";
import {
DEFAULT_MOCKUP_STATE,
DESIGN_HEIGHT,
DESIGN_WIDTH,
MOCKUP_STATES,
type MockupStateId,
MockupWindow,
} from "~/components/mockup";
const ALT =
"Paseo desktop app with coding agents, a conversation, and a code diff open side by side";
const ASPECT_STYLE = { aspectRatio: `${DESIGN_WIDTH} / ${DESIGN_HEIGHT}` };
// `tan(atan2(a, b))` is the CSS way to divide one length by another, so the scale
// factor tracks the container with no JS, no layout thrash, and a correct
// server-rendered first paint.
const SCALE_STYLE = {
width: DESIGN_WIDTH,
height: DESIGN_HEIGHT,
transform: `scale(tan(atan2(100cqw, ${DESIGN_WIDTH}px)))`,
};
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
export function HeroMockup() {
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
return (
<div className="overflow-hidden rounded-xl bg-mock-surface0 p-2 ring-1 ring-white/10 sm:rounded-2xl sm:p-3">
<img
src="/homepage-hero.png"
alt="Paseo desktop app with coding agents, a conversation, and a code diff open side by side"
width={2400}
height={1442}
fetchPriority="high"
decoding="async"
className="block h-auto w-full rounded-lg sm:rounded-xl"
/>
<div>
<div className="mb-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
{MOCKUP_STATES.map((option) => (
<StatePill
key={option.id}
id={option.id}
label={option.label}
selected={option.id === state}
onSelect={setState}
/>
))}
</div>
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 sm:rounded-2xl">
{/* The window is authored at DESIGN_WIDTH and scaled to fit the hero column. */}
<div className="w-full [container-type:inline-size]">
<div
className="relative w-full overflow-hidden rounded-xl sm:rounded-2xl"
style={ASPECT_STYLE}
role="img"
aria-label={ALT}
>
<div className="absolute top-0 left-0 origin-top-left" style={SCALE_STYLE}>
<MockupWindow state={state} />
</div>
</div>
</div>
</div>
</div>
);
}
function StatePill({
id,
label,
selected,
onSelect,
}: {
id: MockupStateId;
label: string;
selected: boolean;
onSelect: (id: MockupStateId) => void;
}) {
const select = useCallback(() => onSelect(id), [onSelect, id]);
return (
<button
type="button"
onClick={select}
aria-pressed={selected}
className={`relative cursor-pointer rounded-full px-2.5 py-1.5 text-xs transition-colors sm:px-3.5 sm:text-sm ${
selected ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{selected ? (
<motion.span
layoutId="hero-mockup-pill"
transition={PILL_TRANSITION}
className="absolute inset-0 rounded-full bg-white/8 ring-1 ring-white/12 ring-inset"
/>
) : null}
<span className="relative">{label}</span>
</button>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,201 @@
// Shared bits of the Paseo UI, drawn small. Everything here is presentational —
// no state, no interactivity. Colors come from the `--color-mock-*` tokens in
// styles.css, which are copied from the app's default dark theme.
import type * as React from "react";
/**
* The `+12 −18` footnote. A diff stat is a *status* signal, not a diff, so it
* uses statusSuccess/statusDanger — see the comment above `lightDiffColors` in
* packages/app/src/styles/theme.ts.
*/
export function DiffStat({ add, remove }: { add: string; remove: string }) {
return (
<span className="flex shrink-0 items-center gap-[4px] text-[11px] tabular-nums">
<span className="text-mock-success">+{add}</span>
<span className="text-mock-danger">-{remove}</span>
</span>
);
}
export type DotTone = "success" | "danger" | "warning" | "running" | "idle";
const DOT_TONE: Record<DotTone, string> = {
success: "bg-mock-dot-success",
danger: "bg-mock-dot-danger",
warning: "bg-mock-dot-warning",
running: "bg-mock-dot-running",
idle: "bg-mock-surface3",
};
/** The 6pt disc on a sidebar row that carries the row's state. */
export function StatusDot({ tone }: { tone: DotTone }) {
return <span className={`size-[7px] shrink-0 rounded-full ${DOT_TONE[tone]}`} />;
}
/** Stands in for the dot on a running row — three stacked ticks, warning hue. */
export function WorkingGlyph() {
return (
<span className="flex h-[10px] w-[7px] shrink-0 flex-col items-center justify-between">
<i className="block size-[2.5px] rounded-full bg-mock-dot-warning" />
<i className="block size-[2.5px] rounded-full bg-mock-dot-warning" />
<i className="block size-[2.5px] rounded-full bg-mock-dot-warning" />
</span>
);
}
/** A pill-shaped count/label badge — PR number, failing checks, host. */
export function MetaBadge({
icon,
children,
className = "text-mock-fg-xmuted",
}: {
icon?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<span className={`flex shrink-0 items-center gap-[3px] text-[10px] ${className}`}>
{icon}
{children}
</span>
);
}
/** Title-bar / toolbar button: transparent until hovered in the real app. */
export function ChromeButton({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span
className={`flex items-center gap-[5px] rounded-[6px] px-[7px] py-[4px] text-[11.5px] text-mock-fg-muted ${className}`}
>
{children}
</span>
);
}
export function VDivider() {
return <span className="h-[16px] w-px shrink-0 bg-mock-border" />;
}
// --- code rendering ---------------------------------------------------------
type SynRole =
| "keyword"
| "comment"
| "string"
| "number"
| "function"
| "class"
| "type"
| "tag"
| "property"
| "variable"
| "punctuation";
const SYN_CLASS: Record<SynRole, string> = {
keyword: "text-syn-keyword",
comment: "text-syn-comment",
string: "text-syn-string",
number: "text-syn-number",
function: "text-syn-function",
class: "text-mock-syn-class",
type: "text-syn-keyword",
tag: "text-syn-tag",
property: "text-syn-property",
variable: "text-syn-variable",
punctuation: "text-syn-punctuation",
};
const KEYWORDS = new Set([
"import",
"export",
"from",
"as",
"function",
"return",
"const",
"let",
"type",
"interface",
"await",
"async",
"new",
"default",
]);
const TYPE_KEYWORDS = new Set(["string", "number", "boolean", "unknown", "never", "void"]);
/** A compact TSX highlighter for the static hero diff. */
export function Code({ line }: { line: string }) {
return (
<>
{tokenize(line).map((token) => (
<span key={token.key} className={token.role ? SYN_CLASS[token.role] : undefined}>
{token.text}
</span>
))}
</>
);
}
interface CodeToken {
key: string;
text: string;
role: SynRole | null;
}
function tokenize(line: string): CodeToken[] {
const parts =
line.match(
/\/\/.*|"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|`(?:\\.|[^`\\])*`?|\d+(?:\.\d+)?|[A-Za-z_$][\w$-]*|\s+|=>|===|!==|==|!=|<=|>=|&&|\|\||\?\.|[^\s]/g,
) ?? [];
return parts.map((text, index) => {
const role = classify(parts, index);
return { key: `${index}.${text}`, text, role };
});
}
function significantToken(parts: string[], index: number, direction: -1 | 1): string {
for (let cursor = index + direction; cursor >= 0 && cursor < parts.length; cursor += direction) {
const candidate = parts[cursor];
if (candidate && !/^\s+$/.test(candidate)) return candidate;
}
return "";
}
function isInsideJsxTag(parts: string[], index: number): boolean {
const prefix = parts.slice(0, index).join("");
return prefix.lastIndexOf("<") > prefix.lastIndexOf(">");
}
function classify(parts: string[], index: number): SynRole | null {
const part = parts[index] ?? "";
if (/^\s+$/.test(part)) return null;
if (/^["'`]/.test(part)) return "string";
if (part.startsWith("//")) return "comment";
if (KEYWORDS.has(part)) return "keyword";
if (TYPE_KEYWORDS.has(part)) return "type";
if (/^\d/.test(part)) return "number";
if (!/^[A-Za-z_$]/.test(part)) return "punctuation";
return classifyIdentifier(parts, index, part);
}
function classifyIdentifier(parts: string[], index: number, part: string): SynRole {
const previous = significantToken(parts, index, -1);
const next = significantToken(parts, index, 1);
const insideJsxTag = isInsideJsxTag(parts, index);
if (previous === "<" || (previous === "/" && insideJsxTag)) return "tag";
if (insideJsxTag && next === "=") return "property";
if (!insideJsxTag && next === ":") return "property";
if (previous === ":" || previous === "as") return "type";
if (next === "(") return "function";
if (/^[A-Z]/.test(part)) return "class";
if (part.includes("-")) return "property";
return "variable";
}
@@ -0,0 +1,260 @@
import {
AudioLines,
BookOpen,
Brain,
ChevronDown,
CircleDot,
Copy,
FilePenLine,
LoaderCircle,
ListTodo,
Mic,
Plus,
ShieldOff,
Split,
SquareTerminal,
Zap,
} from "lucide-react";
import type * as React from "react";
import { DiffStat } from "./atoms";
import { CodexIcon } from "./icons";
// Icons passed as props are hoisted so they are not rebuilt on every render.
const MODEL_ICON = <CodexIcon size={13} />;
const EFFORT_ICON = <Brain size={13} />;
const ACCESS_ICON = <ShieldOff size={13} />;
// --- transcript -------------------------------------------------------------
function Bullet({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-[10px] text-[13.5px] leading-[22px] text-mock-fg">
<span className="text-mock-fg-muted">·</span>
<span>{children}</span>
</div>
);
}
function Para({ children }: { children: React.ReactNode }) {
return <p className="text-[13.5px] leading-[20px] text-mock-fg">{children}</p>;
}
/** The user's message — a right-aligned bubble that opens the turn. */
function UserBubble({ children }: { children: React.ReactNode }) {
return (
<div className="flex justify-end pb-[12px]">
<span className="max-w-[80%] rounded-[11px] bg-mock-surface2 px-[13px] py-[8px] text-[13px] leading-[19px] text-mock-fg">
{children}
</span>
</div>
);
}
/** The message footer — copy, branch off, and how long the turn took. */
function TurnFooter({ duration }: { duration: string }) {
return (
<div className="flex items-center gap-[16px] pt-[14px] text-mock-fg-muted">
<Copy size={14} />
<Split size={14} />
<span className="text-[12.5px]">Worked for {duration}</span>
</div>
);
}
export function AgentTranscript() {
return (
<div className="space-y-[13px] pt-[2px]">
<Para>
The homepage is built. It leads with Paseo&rsquo;s actual value, then uses the product
itself to prove the story instead of falling back to generic dashboard art.
</Para>
<div>
<Bullet>Reworked the hero around &ldquo;The control plane for coding agents.&rdquo;</Bullet>
<Bullet>Built desktop and mobile scenes from real Paseo workflows and assets.</Bullet>
<Bullet>Added believable agent, review, plugin, shipping, and host states.</Bullet>
<Bullet>
Matched the app&rsquo;s tabs, explorer, diff tree, controls, and layout rails.
</Bullet>
</div>
<TurnFooter duration="31m 46s" />
<UserBubble>Create a PR</UserBubble>
<Para>PR opened: Rebuild the homepage around live Paseo workflows.</Para>
<span className="block text-[13.5px] text-mock-accent-bright">
https://github.com/getpaseo/paseo/pull/3981
</span>
<div>
<Bullet>Ships the new product-led hero and interactive desktop mockup.</Bullet>
<Bullet>Includes responsive mobile proof and the supporting homepage story.</Bullet>
<Bullet>Formatting, typecheck, and lint are clean.</Bullet>
</div>
<Para>Ready for review.</Para>
<TurnFooter duration="1m 18s" />
</div>
);
}
interface PluginToolCall {
kind: "read" | "shell" | "write";
label: string;
detail: string;
}
const PLUGIN_TOOL_CALLS: PluginToolCall[] = [
{ kind: "read", label: "Read", detail: "paseo-plugin/SKILL.md" },
{
kind: "shell",
label: "Shell",
detail: "paseo plugin init ~/dev/release-radar",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/index.ts",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/release-radar.client.tsx",
},
{
kind: "shell",
label: "Shell",
detail: "npm install && npm run typecheck",
},
{
kind: "shell",
label: "Shell",
detail: "paseo plugin install ~/dev/release-radar",
},
];
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
function PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) {
if (kind === "read") return <BookOpen size={14} />;
if (kind === "write") return <FilePenLine size={14} />;
return <SquareTerminal size={14} />;
}
function PluginToolRow({ call }: { call: PluginToolCall }) {
return (
<div className="flex items-center gap-[9px] py-[5px] text-[12.5px] leading-[18px]">
<span className="text-mock-fg-muted">
<PluginToolIcon kind={call.kind} />
</span>
<span className="shrink-0 text-mock-fg">{call.label}</span>
<span className="min-w-0 truncate font-mono text-[11.5px] text-mock-fg-muted">
{call.detail}
</span>
</div>
);
}
function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) {
return (
<div>
{calls.map((call) => (
<PluginToolRow key={`${call.label}:${call.detail}`} call={call} />
))}
</div>
);
}
export function PluginBuildTranscript() {
return (
<div className="space-y-[13px] pt-[2px]">
<UserBubble>
Build me a release radar for Paseo. I want an explorer panel showing merged PRs, changes in
review, failing checks, and seven-day activity, accessible from the sidebar.
</UserBubble>
<Para>
I&rsquo;ll scaffold a local plugin, add the explorer surface, and wire it into the sidebar.
</Para>
<PluginToolGroup calls={PLUGIN_DISCOVERY_CALLS} />
<Para>
The plugin API exposes Paseo&rsquo;s live project and workspace state, so the radar
doesn&rsquo;t need a separate service.
</Para>
<PluginToolGroup calls={PLUGIN_BUILD_CALLS} />
<Para>The surface is wired. I&rsquo;m checking the project and installing it now.</Para>
<PluginToolGroup calls={PLUGIN_INSTALL_CALLS} />
<Para>Release Radar is live. It should now be visible in the right sidebar.</Para>
<TurnFooter duration="2m 14s" />
</div>
);
}
// --- composer ---------------------------------------------------------------
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
{icon}
{label}
<ChevronDown size={11} className="text-mock-fg-xmuted" />
</span>
);
}
/** A rounded pill that floats above the composer — task tracker, subagents, diff. */
function ComposerPill({ children }: { children: React.ReactNode }) {
return (
<span className="flex items-center gap-[6px] rounded-full border border-mock-border-accent px-[11px] py-[4px] text-[11.5px] text-mock-fg-muted">
{children}
</span>
);
}
/** The amber ring the app shows while a turn is streaming. */
function BusyRing() {
return <LoaderCircle size={14} className="text-mock-warning" strokeWidth={2.4} />;
}
export function Composer({
showProgress = true,
busy = true,
}: {
showProgress?: boolean;
busy?: boolean;
}) {
return (
<div className="shrink-0 px-[27px] pt-[16px] pb-[17px]">
<div className={showProgress ? "pb-[13px]" : ""}>
{showProgress ? (
<div className="flex items-center gap-[8px]">
<ComposerPill>6/6 tasks</ComposerPill>
<ComposerPill>
<CircleDot size={12} className="text-mock-dot-running" />3 subagents
</ComposerPill>
<ComposerPill>
<DiffStat add="1.9k" remove="684" />
</ComposerPill>
</div>
) : null}
</div>
<div className="rounded-[11px] border border-mock-border-accent bg-mock-surface1">
<div className="px-[14px] pt-[13px] pb-[12px]">
<div className="flex items-start gap-[10px]">
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
Message the agent, tag @files, or use /commands and /skills
</span>
</div>
<div className="flex items-center gap-[17px] pt-[26px]">
<Plus size={15} className="text-mock-fg-muted" />
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
<ControlPill icon={EFFORT_ICON} label="Medium" />
<ControlPill icon={ACCESS_ICON} label="Full access" />
<Zap size={14} className="text-mock-fg-muted" />
<ListTodo size={14} className="text-mock-fg-muted" />
<span className="flex-1" />
{busy ? <BusyRing /> : null}
<Mic size={14} className="text-mock-fg-muted" />
<AudioLines size={14} className="text-mock-fg-muted" />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,778 @@
import {
ChevronDown,
ChevronRight,
Columns2,
Ellipsis,
List,
Minus,
Plus,
RefreshCw,
Rows3,
} from "lucide-react";
import { Code, DiffStat } from "./atoms";
import { TABBAR_H } from "./geometry";
import { ReactFileIcon } from "./icons";
// Row height and the gutter width are the two numbers that make a diff read as a
// diff. Everything else follows them.
const ROW_H = 18.5;
const HUNK_H = 23;
const GUTTER_W = 42;
const ROW_STYLE = { height: ROW_H };
const HUNK_STYLE = { height: HUNK_H };
const GUTTER_STYLE = { width: GUTTER_W };
const SCROLLBAR_ROW_STYLE = { height: 9 };
const SUBHEADER_STYLE = { height: TABBAR_H };
export type DiffRow =
| { kind: "file"; name: string; path?: string; add: string; remove: string }
| { kind: "hunk"; label: string }
| { kind: "line"; n: string; text: string; mark?: DiffMark }
| { kind: "scrollbar" }
| { kind: "gap" };
type DiffMark = "add" | "remove";
// The row fill and the gutter number are the whole visual language of a diff:
// status hue at low alpha behind the row, full status hue on the number.
const ROW_FILL: Record<DiffMark, string> = {
add: "bg-mock-diff-add",
remove: "bg-mock-diff-remove",
};
const NUMBER_COLOR: Record<DiffMark, string> = {
add: "text-mock-success",
remove: "text-mock-danger",
};
function DiffLine({ n, text, mark }: { n: string; text: string; mark?: DiffMark }) {
const fill = mark ? ROW_FILL[mark] : "";
const number = mark ? NUMBER_COLOR[mark] : "text-mock-fg-muted";
return (
<div className={`flex items-center ${fill}`} style={ROW_STYLE}>
<span
className={`flex h-full shrink-0 items-center justify-end border-r border-mock-border pr-[10px] font-mono text-[11px] tabular-nums ${number}`}
style={GUTTER_STYLE}
>
{n}
</span>
<span className="min-w-0 flex-1 overflow-hidden whitespace-pre pl-[10px] font-mono text-[12px] leading-[18.5px]">
<Code line={text} />
</span>
</div>
);
}
function FileRow({
name,
path,
add,
remove,
}: {
name: string;
path?: string;
add: string;
remove: string;
}) {
return (
<div className="flex h-[36px] items-center gap-[8px] border-b border-mock-border bg-mock-surface0 pr-[14px] pl-[16px]">
<ChangesFileIcon name={name} />
<span className="min-w-0 flex-1 truncate text-[12px]">
<span className="text-mock-fg">{name}</span>
{path ? <span className="ml-[6px] text-mock-fg-muted">{path}</span> : null}
</span>
<DiffStat add={add} remove={remove} />
</div>
);
}
export function DiffRows({ rows, flush }: { rows: DiffRow[]; flush?: boolean }) {
return (
<div className={`relative overflow-hidden bg-mock-surface1 ${flush ? "" : "min-h-0 flex-1"}`}>
{rows.map((row, index) => {
const key = `${row.kind}-${index}`;
if (row.kind === "file") return <FileRow key={key} {...row} />;
if (row.kind === "scrollbar")
return (
<div key={key} className="flex items-center pl-[46px]" style={SCROLLBAR_ROW_STYLE}>
<span className="h-[5px] w-[110px] rounded-full bg-mock-surface4" />
</div>
);
if (row.kind === "gap") return <div key={key} style={ROW_STYLE} />;
if (row.kind === "hunk")
return (
<div key={key} className="flex items-center" style={HUNK_STYLE}>
<span className="h-full shrink-0 border-r border-mock-border" style={GUTTER_STYLE} />
<span className="pl-[10px] font-mono text-[11px] text-mock-fg-muted">
{row.label}
</span>
</div>
);
return <DiffLine key={key} {...row} />;
})}
{!flush ? (
<span className="pointer-events-none absolute top-[86px] right-[3px] bottom-[8px] w-[4px] rounded-full bg-mock-surface2">
<span className="mt-[112px] block h-[92px] rounded-full bg-mock-surface4" />
</span>
) : null}
</div>
);
}
/** The `Uncommitted ⌄` scope row with the view-mode controls on the right. */
export function ScopeRow({ label }: { label: string }) {
return (
<div className="flex h-[31px] shrink-0 items-center border-b border-mock-border px-[14px]">
<span className="flex items-center gap-[5px] text-[12px] text-mock-fg">
{label}
<ChevronDown size={12} className="text-mock-fg-muted" />
</span>
<span className="flex-1" />
<span className="flex items-center gap-[13px] text-mock-fg-muted">
<Columns2 size={13} />
<span className="rounded-[5px] bg-mock-surface2 p-[3px] text-mock-fg">
<List size={12} />
</span>
<Rows3 size={13} />
<ChevronDown size={12} />
</span>
</div>
);
}
// --- flat diff tree ---------------------------------------------------------
// The default diff view is a flat file list: name · path · +N -M · expand box.
export interface TreeFile {
name: string;
path?: string;
add: string;
remove: string;
}
/** The expand affordance on a tree row — green + for pure additions, else a dot. */
function ExpandBox({ pure }: { pure: boolean }) {
return (
<span
className={`flex size-[15px] shrink-0 items-center justify-center rounded-[4px] border ${
pure
? "border-mock-success/60 text-mock-success"
: "border-mock-warning/50 text-mock-warning"
}`}
>
{pure ? (
<Plus size={11} strokeWidth={2.4} />
) : (
<i className="block size-[3px] rounded-full bg-current" />
)}
</span>
);
}
/** One row of the flat diff tree. */
export function DiffTreeRow({ file }: { file: TreeFile }) {
const pure = file.remove === "0";
return (
<div className="flex items-center gap-[10px] border-b border-mock-border px-[14px] py-[7px]">
<span className="min-w-0 flex-1 truncate text-[12.5px]">
<span className="text-mock-fg">{file.name}</span>
{file.path ? <span className="text-mock-fg-muted"> {file.path}</span> : null}
</span>
<span className="flex shrink-0 items-center gap-[4px] text-[11px] tabular-nums">
<span className="text-mock-success">+{file.add}</span>
<span className={pure ? "text-mock-fg-xmuted" : "text-mock-danger"}>-{file.remove}</span>
</span>
<ExpandBox pure={pure} />
</div>
);
}
export function DiffTree({ files }: { files: TreeFile[] }) {
return (
<div className="min-h-0 flex-1 overflow-hidden">
{files.map((file) => (
<DiffTreeRow key={`${file.path ?? ""}/${file.name}`} file={file} />
))}
</div>
);
}
export const REVIEW_TREE: TreeFile[] = [
{ name: "providers.md", path: "docs", add: "3", remove: "1" },
{
name: "bridge-plugin.mjs",
path: "packages/server/src/server/agent/providers/opencode",
add: "146",
remove: "0",
},
{
name: "bridge.test.ts",
path: "packages/server/src/server/agent/providers/opencode",
add: "181",
remove: "0",
},
{
name: "bridge.ts",
path: "packages/server/src/server/agent/providers/opencode",
add: "284",
remove: "0",
},
{
name: "server-manager.ts",
path: "packages/server/src/server/agent/providers/opencode",
add: "13",
remove: "1",
},
{
name: "opencode-agent.test.ts",
path: "packages/server/src/server/agent/providers",
add: "40",
remove: "0",
},
{
name: "opencode-agent.ts",
path: "packages/server/src/server/agent/providers",
add: "58",
remove: "8",
},
{
name: "opencode-bridge-adapter.test.ts",
path: "packages/server/src/server/agent/providers",
add: "123",
remove: "0",
},
{
name: "opencode-bridge.local.e2e.test.ts",
path: "packages/server/src/server/agent/providers",
add: "158",
remove: "0",
},
{
name: "provider-registry.ts",
path: "packages/server/src/server/agent",
add: "10",
remove: "2",
},
{
name: "provider-snapshot-manager.ts",
path: "packages/server/src/server/agent",
add: "5",
remove: "0",
},
{ name: "bootstrap.ts", path: "packages/server/src/server", add: "12", remove: "0" },
{ name: "package.json", path: "packages/server", add: "2", remove: "1" },
{ name: "trace-daemon.mjs", path: "scripts", add: "2", remove: "0" },
{ name: "package-lock.json", add: "260", remove: "1" },
];
// --- explorer changes tree -------------------------------------------------
interface ChangesTreeFolder {
kind: "folder";
name: string;
depth: ChangesTreeDepth;
add: string;
remove: string;
}
interface ChangesTreeFile {
kind: "file";
name: string;
depth: ChangesTreeDepth;
add: string;
remove: string;
status: ChangeStatus;
}
type ChangesTreeEntry = ChangesTreeFolder | ChangesTreeFile;
type ChangesTreeDepth = 0 | 1 | 2 | 3 | 4;
type ChangeStatus = "added" | "modified" | "deleted";
const CHANGES_TREE: ChangesTreeEntry[] = [
{ kind: "folder", name: "packages/website/src", depth: 0, add: "1.9k", remove: "684" },
{ kind: "folder", name: "components", depth: 1, add: "1.5k", remove: "512" },
{ kind: "folder", name: "mockup", depth: 2, add: "1.3k", remove: "389" },
{ kind: "folder", name: "mobile", depth: 3, add: "480", remove: "231" },
{ kind: "file", name: "atoms.tsx", depth: 4, add: "56", remove: "18", status: "modified" },
{ kind: "file", name: "chat.tsx", depth: 4, add: "155", remove: "74", status: "modified" },
{ kind: "file", name: "frame.tsx", depth: 4, add: "91", remove: "0", status: "added" },
{ kind: "file", name: "legacy-tabs.tsx", depth: 4, add: "0", remove: "139", status: "deleted" },
{ kind: "folder", name: "desktop", depth: 3, add: "612", remove: "143" },
{ kind: "file", name: "window.tsx", depth: 4, add: "167", remove: "42", status: "modified" },
{ kind: "file", name: "titlebar.tsx", depth: 4, add: "117", remove: "61", status: "modified" },
{ kind: "file", name: "toolbar.tsx", depth: 4, add: "204", remove: "0", status: "added" },
{ kind: "file", name: "old-tabs.tsx", depth: 4, add: "0", remove: "40", status: "deleted" },
{ kind: "folder", name: "shared", depth: 3, add: "194", remove: "15" },
{ kind: "file", name: "icons.tsx", depth: 4, add: "62", remove: "15", status: "modified" },
{ kind: "file", name: "geometry.ts", depth: 4, add: "17", remove: "0", status: "added" },
{ kind: "file", name: "states.ts", depth: 4, add: "115", remove: "0", status: "added" },
{ kind: "file", name: "hero-mockup.tsx", depth: 2, add: "89", remove: "52", status: "modified" },
{ kind: "folder", name: "routes", depth: 1, add: "211", remove: "96" },
{ kind: "file", name: "index.tsx", depth: 2, add: "44", remove: "70", status: "modified" },
{ kind: "file", name: "download.tsx", depth: 2, add: "167", remove: "0", status: "added" },
{ kind: "folder", name: "styles", depth: 1, add: "68", remove: "76" },
{ kind: "file", name: "mockup.css", depth: 2, add: "68", remove: "47", status: "modified" },
{ kind: "file", name: "legacy.css", depth: 2, add: "0", remove: "29", status: "deleted" },
];
/** The open workspace diff matches the selected file in the Changes tree. */
export const REVIEW_DIFF_ROWS: DiffRow[] = [
{
kind: "file",
name: "chat.tsx",
path: "packages/website/src/components/mockup/mobile",
add: "155",
remove: "74",
},
{ kind: "hunk", label: "@@ -22,5 +23,11 @@" },
{ kind: "line", n: "22", text: "const TOOL_ROWS = [", mark: "remove" },
{
kind: "line",
n: "23",
text: 'type ToolKind = "read" | "shell";',
mark: "add",
},
{ kind: "line", n: "24", text: "" },
{
kind: "line",
n: "25",
text: "interface ToolCall {",
mark: "add",
},
{ kind: "line", n: "26", text: " kind: ToolKind;", mark: "add" },
{ kind: "line", n: "27", text: " label: string;", mark: "add" },
{ kind: "line", n: "28", text: " command: string;", mark: "add" },
{ kind: "line", n: "29", text: "}", mark: "add" },
{ kind: "line", n: "30", text: "" },
{
kind: "line",
n: "31",
text: "const TOOL_CALLS: ToolCall[] = [",
mark: "add",
},
{ kind: "line", n: "32", text: " {", mark: "add" },
{ kind: "line", n: "33", text: ' ...readToolCall("docs/design.md"),', mark: "add" },
{ kind: "line", n: "34", text: " },", mark: "add" },
{ kind: "line", n: "35", text: "];", mark: "add" },
{ kind: "line", n: "36", text: "" },
{
kind: "file",
name: "titlebar.tsx",
path: "packages/website/src/components/mockup/desktop",
add: "117",
remove: "61",
},
{ kind: "hunk", label: "@@ -88,6 +92,16 @@" },
{
kind: "line",
n: "88",
text: "const AGENT_TABS: Tab[] = [",
mark: "remove",
},
{
kind: "line",
n: "92",
text: "const WORKSPACE_TABS: Tab[] = [",
mark: "add",
},
{
kind: "line",
n: "95",
text: ' { id: "terminal", label: "npm run dev", icon: <SquareTerminal /> },',
mark: "add",
},
{
kind: "line",
n: "96",
text: ' { id: "browser", label: "https://localhost:3000", icon: <Globe /> },',
mark: "add",
},
{ kind: "line", n: "97", text: "];" },
{ kind: "line", n: "98", text: "" },
{
kind: "file",
name: "window.tsx",
path: "packages/website/src/components/mockup/desktop",
add: "167",
remove: "42",
},
{ kind: "hunk", label: "@@ -118,7 +124,7 @@" },
{ kind: "line", n: "118", text: " <TitleBar state={state} />" },
{ kind: "line", n: "119", text: " <TabStrip />", mark: "remove" },
{ kind: "line", n: "124", text: " <TabStrip state={state} />", mark: "add" },
{ kind: "line", n: "125", text: ' <div className="relative min-h-0 flex-1">' },
{ kind: "line", n: "126", text: " <AnimatePresence initial={false}>" },
{
kind: "file",
name: "mockup.css",
path: "packages/website/src/styles",
add: "68",
remove: "47",
},
{ kind: "hunk", label: "@@ -54,4 +61,4 @@" },
{
kind: "line",
n: "54",
text: "--color-mock-diff-add: rgba(108, 177, 123, 0.15);",
mark: "remove",
},
{
kind: "line",
n: "61",
text: "--color-mock-diff-add: rgba(108, 177, 123, 0.08);",
mark: "add",
},
{
kind: "line",
n: "55",
text: "--color-mock-diff-remove: rgba(216, 132, 123, 0.1);",
mark: "remove",
},
{
kind: "line",
n: "62",
text: "--color-mock-diff-remove: rgba(216, 132, 123, 0.07);",
mark: "add",
},
];
const TREE_DEPTH_PADDING = [
"pl-[14px]",
"pl-[27px]",
"pl-[40px]",
"pl-[53px]",
"pl-[66px]",
] as const;
function ChangesFileIcon({ name }: { name: string }) {
if (name.endsWith(".tsx")) return <ReactFileIcon size={13} />;
const label = name.endsWith(".css") ? "CSS" : "TS";
const color = name.endsWith(".css") ? "bg-purple-400/70" : "bg-sky-400/70";
return (
<span
className={`flex h-[13px] w-[15px] shrink-0 items-center justify-center text-[6px] font-bold text-mock-surface0 ${color}`}
>
{label}
</span>
);
}
function ChangeStatusBox({ status }: { status: ChangeStatus }) {
if (status === "added") return <ExpandBox pure />;
const deleted = status === "deleted";
return (
<span
className={`flex size-[15px] shrink-0 items-center justify-center rounded-[4px] border ${
deleted
? "border-mock-danger/50 text-mock-danger"
: "border-mock-warning/50 text-mock-warning"
}`}
>
{deleted ? (
<Minus size={11} strokeWidth={2.4} />
) : (
<i className="block size-[3px] rounded-full bg-current" />
)}
</span>
);
}
function ChangesTreeRow({ entry, selected }: { entry: ChangesTreeEntry; selected: boolean }) {
const hasAdditions = entry.add !== "0";
const hasRemovals = entry.remove !== "0";
return (
<div
className={`flex h-[29px] items-center gap-[8px] pr-[10px] ${TREE_DEPTH_PADDING[entry.depth]} ${
selected ? "bg-mock-surface2" : ""
}`}
>
{entry.kind === "folder" ? (
<ChevronDown size={13} className="shrink-0 text-mock-fg-muted" />
) : null}
{entry.kind === "file" ? <ChangesFileIcon name={entry.name} /> : null}
<span className="min-w-0 flex-1 truncate text-[12px] text-mock-fg">{entry.name}</span>
<span className="flex shrink-0 items-center gap-[4px] text-[11px] tabular-nums">
<span className={hasAdditions ? "text-mock-success" : "text-mock-fg-xmuted"}>
+{entry.add}
</span>
<span className={hasRemovals ? "text-mock-danger" : "text-mock-fg-xmuted"}>
-{entry.remove}
</span>
</span>
{entry.kind === "file" ? (
<ChangeStatusBox status={entry.status} />
) : (
<span className="size-[15px] shrink-0" />
)}
</div>
);
}
export function ExplorerChangesTree({ selectedFile }: { selectedFile?: string }) {
return (
<div className="min-h-0 flex-1 overflow-hidden">
{CHANGES_TREE.map((entry) => (
<ChangesTreeRow
key={`${entry.depth}:${entry.name}`}
entry={entry}
selected={entry.kind === "file" && entry.name === selectedFile}
/>
))}
</div>
);
}
export function ChangesBranchRow() {
return (
<div
className="flex shrink-0 items-center gap-[6px] border-b border-mock-border px-[14px] text-[12px] text-mock-fg-muted"
style={SUBHEADER_STYLE}
>
main
<ChevronDown size={12} />
</div>
);
}
export function ChangesScopeRow() {
return (
<div className="flex h-[36px] shrink-0 items-center border-b border-mock-border px-[14px]">
<span className="flex items-center gap-[5px] text-[12px] text-mock-fg-muted">
Uncommitted
<ChevronDown size={12} />
</span>
<span className="ml-[9px] flex items-center gap-[5px] text-[11.5px] tabular-nums">
<span className="text-mock-success">+1.9k</span>
<span className="text-mock-danger">-684</span>
</span>
<span className="flex-1" />
<span className="flex items-center gap-[12px] text-mock-fg-muted">
<RefreshCw size={13} />
<Ellipsis size={14} />
</span>
</div>
);
}
export function ChangesCommitsRow() {
return (
<div className="flex h-[36px] shrink-0 items-center gap-[8px] border-t border-mock-border px-[10px] text-[12px] text-mock-fg">
<ChevronRight size={13} className="text-mock-fg-muted" />
Commits
</div>
);
}
/** The hunk shown when the first review file is expanded. */
export const PROVIDERS_HUNK: DiffRow[] = [
{ kind: "hunk", label: "@@ -75,7 +75,9 @@" },
{ kind: "line", n: "75", text: "" },
{
kind: "line",
n: "76",
text: "Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged",
},
{ kind: "line", n: "77", text: "" },
{
kind: "line",
n: "78",
text: "OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint",
mark: "remove",
},
];
/** The hunk continues below the inline comment box. */
export const PROVIDERS_HUNK_TAIL: DiffRow[] = [
{
kind: "line",
n: "78",
text: "OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares",
mark: "add",
},
{ kind: "line", n: "79", text: "", mark: "add" },
{
kind: "line",
n: "80",
text: "An agent with custom environment variables or user-configured MCP servers gets a server",
mark: "add",
},
{ kind: "line", n: "81", text: "" },
{
kind: "line",
n: "82",
text: "OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs",
},
{ kind: "line", n: "83", text: "" },
];
// --- content ----------------------------------------------------------------
export const UNCOMMITTED_ROWS: DiffRow[] = [
{ kind: "file", name: "alert.tsx", add: "12", remove: "18" },
{ kind: "line", n: "49", text: " <div" },
{ kind: "line", n: "50", text: ' data-slot="alert-description"' },
{ kind: "line", n: "51", text: " className={cn(" },
{
kind: "line",
n: "52",
text: ' "col-start-2 grid justify-items-start gap-1 text-sm tex',
},
{ kind: "line", n: "59", text: " className", mark: "remove" },
{ kind: "line", n: "53", text: " className,", mark: "add" },
{ kind: "line", n: "54", text: " )}" },
{ kind: "line", n: "55", text: " {...props}" },
{ kind: "line", n: "56", text: " />" },
{ kind: "line", n: "63", text: " )", mark: "remove" },
{ kind: "line", n: "57", text: " );", mark: "add" },
{ kind: "line", n: "58", text: "}" },
{ kind: "line", n: "59", text: "" },
{
kind: "line",
n: "66",
text: "export { Alert, AlertTitle, AlertDescription }",
mark: "remove",
},
{ kind: "line", n: "60", text: "export { Alert, AlertTitle, AlertDescription };", mark: "add" },
{ kind: "scrollbar" },
{ kind: "file", name: "badge.tsx", add: "11", remove: "13" },
{ kind: "file", name: "button.tsx", add: "12", remove: "14" },
{ kind: "file", name: "card.tsx", add: "13", remove: "30" },
{ kind: "hunk", label: "@@ -1,6 +1,6 @@" },
{ kind: "line", n: "1", text: 'import * as React from "react"', mark: "remove" },
{ kind: "line", n: "1", text: 'import * as React from "react";', mark: "add" },
{ kind: "line", n: "2", text: "" },
{ kind: "line", n: "3", text: 'import { cn } from "@/lib/utils"', mark: "remove" },
{ kind: "line", n: "3", text: 'import { cn } from "../../lib/utils.js";', mark: "add" },
{ kind: "line", n: "4", text: "" },
{
kind: "line",
n: "5",
text: 'function Card({ className, ...props }: React.ComponentProps<"di',
},
{ kind: "line", n: "6", text: " return (" },
{ kind: "hunk", label: "@@ -8,11 +8,11 @@" },
{ kind: "line", n: "8", text: ' data-slot="card"' },
{ kind: "line", n: "9", text: " className={cn(" },
{
kind: "line",
n: "10",
text: ' "flex flex-col gap-6 rounded-xl border bg-card py-6 tex',
},
{ kind: "line", n: "11", text: " className", mark: "remove" },
{ kind: "line", n: "11", text: " className,", mark: "add" },
{ kind: "line", n: "12", text: " )}" },
{ kind: "line", n: "13", text: " {...props}" },
{ kind: "line", n: "14", text: " />" },
{ kind: "line", n: "15", text: " )", mark: "remove" },
{ kind: "line", n: "15", text: " );", mark: "add" },
{ kind: "line", n: "16", text: "}" },
{ kind: "line", n: "17", text: "" },
];
/** Same change set, room to breathe — used by the wide review pane. */
export const REVIEW_ROWS: DiffRow[] = [
{ kind: "hunk", label: "@@ -1,6 +1,6 @@" },
{ kind: "line", n: "1", text: 'import * as React from "react"', mark: "remove" },
{ kind: "line", n: "1", text: 'import * as React from "react";', mark: "add" },
{ kind: "line", n: "2", text: "" },
{ kind: "line", n: "3", text: 'import { cn } from "@/lib/utils"', mark: "remove" },
{ kind: "line", n: "3", text: 'import { cn } from "../../lib/utils.js";', mark: "add" },
{ kind: "line", n: "4", text: "" },
{
kind: "line",
n: "5",
text: 'function Card({ className, ...props }: React.ComponentProps<"div">) {',
},
{ kind: "line", n: "6", text: " return (" },
{ kind: "line", n: "7", text: " <div" },
{ kind: "hunk", label: "@@ -8,15 +8,15 @@ function Card({ className, ...props })" },
{ kind: "line", n: "8", text: ' data-slot="card"' },
{ kind: "line", n: "9", text: " className={cn(" },
{
kind: "line",
n: "10",
text: ' "flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground",',
},
{ kind: "line", n: "11", text: " className", mark: "remove" },
{ kind: "line", n: "11", text: " className,", mark: "add" },
{ kind: "line", n: "12", text: " )}" },
{ kind: "line", n: "13", text: " {...props}" },
{ kind: "line", n: "14", text: " />" },
{ kind: "line", n: "15", text: " )", mark: "remove" },
{ kind: "line", n: "15", text: " );", mark: "add" },
{ kind: "line", n: "16", text: "}" },
{ kind: "line", n: "17", text: "" },
{ kind: "hunk", label: "@@ -21,9 +21,9 @@ function CardHeader({ className, ...props })" },
{ kind: "line", n: "21", text: "function CardHeader({ className, ...props }) {" },
{ kind: "line", n: "22", text: " return (" },
{ kind: "line", n: "23", text: " <div" },
{ kind: "line", n: "24", text: ' data-slot="card-header"' },
{
kind: "line",
n: "25",
text: ' className={cn("grid auto-rows-min items-start", className)}',
mark: "remove",
},
{
kind: "line",
n: "25",
text: ' className={cn("grid auto-rows-min items-start gap-1.5", className)}',
mark: "add",
},
{ kind: "line", n: "26", text: " {...props}" },
{ kind: "line", n: "27", text: " />" },
{ kind: "line", n: "28", text: " );" },
{ kind: "line", n: "29", text: "}" },
{ kind: "line", n: "30", text: "" },
{ kind: "hunk", label: "@@ -36,12 +36,12 @@ function CardTitle({ className, ...props })" },
{ kind: "line", n: "36", text: "function CardTitle({ className, ...props }) {" },
{ kind: "line", n: "37", text: " return (" },
{ kind: "line", n: "38", text: " <div" },
{ kind: "line", n: "39", text: ' data-slot="card-title"' },
{
kind: "line",
n: "40",
text: ' className={cn("leading-none", className)}',
mark: "remove",
},
{
kind: "line",
n: "40",
text: ' className={cn("leading-none font-semibold", className)}',
mark: "add",
},
{ kind: "line", n: "41", text: " {...props}" },
{ kind: "line", n: "42", text: " />" },
{ kind: "line", n: "43", text: " )", mark: "remove" },
{ kind: "line", n: "43", text: " );", mark: "add" },
{ kind: "line", n: "44", text: "}" },
{ kind: "line", n: "45", text: "" },
{
kind: "line",
n: "46",
text: "export { Card, CardHeader, CardTitle, CardContent }",
mark: "remove",
},
{
kind: "line",
n: "46",
text: "export { Card, CardHeader, CardTitle, CardContent };",
mark: "add",
},
];
export const REVIEW_FILES: {
name: string;
path: string;
add: string;
remove: string;
active?: boolean;
}[] = [
{ name: "alert.tsx", path: "components/ui", add: "12", remove: "18" },
{ name: "badge.tsx", path: "components/ui", add: "11", remove: "13" },
{ name: "button.tsx", path: "components/ui", add: "12", remove: "14" },
{ name: "card.tsx", path: "components/ui", add: "13", remove: "30", active: true },
{ name: "sidebar.tsx", path: "components/ui", add: "48", remove: "9" },
{ name: "utils.ts", path: "lib", add: "3", remove: "1" },
];
@@ -0,0 +1,17 @@
/**
* The mockup is authored at one fixed size — "design pixels" — and scaled to
* whatever width its container gives it. Every measurement in the mockup is a
* plain px number you can reason about directly, and nothing has to change when
* the hero column gets wider or narrower.
*
* The numbers come from measuring public/homepage-hero.png (a 2x capture of a
* 2249x1289 window region) and dividing by 1.5617 so the window lands at 1440
* wide — the widest the hero column ever renders it.
*/
export const DESIGN_WIDTH = 1440;
export const DESIGN_HEIGHT = 826;
export const TITLEBAR_H = 39;
export const TABBAR_H = 31;
export const SIDEBAR_W = 262;
export const RIGHT_PANEL_W = 424;
@@ -1,5 +1,37 @@
// Exact SVG paths from packages/app/src/components/icons/
/** The Paseo brand swirl — public/logo.svg, drawn as a small sidebar avatar mark. */
export function PaseoMark({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 700 700"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" />
</svg>
);
}
/** GitHub mark for the toolbar and PR panel. */
export function GithubGlyph({ size = 14, className }: { size?: number; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.87-.01-1.71-2.78.62-3.37-1.37-3.37-1.37-.46-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.89 1.57 2.34 1.12 2.91.85.09-.66.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.06 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.28 2.75 1.05a9.32 9.32 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.93-2.34 4.79-4.57 5.05.36.32.68.94.68 1.9 0 1.37-.01 2.48-.01 2.82 0 .27.18.6.69.49A10.02 10.02 0 0 0 22 12.25C22 6.58 17.52 2 12 2Z" />
</svg>
);
}
export function ClaudeIcon({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg
@@ -30,6 +62,36 @@ export function CodexIcon({ size = 13, className }: { size?: number; className?:
);
}
/** Material file-icon theme's React mark — what the app shows next to a .tsx file. */
export function ReactFileIcon({ size = 11, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx={12} cy={12} r={2.2} fill="#61dafb" />
<g stroke="#61dafb" strokeWidth={1.2} fill="none">
<ellipse cx={12} cy={12} rx={9.6} ry={4} />
<ellipse cx={12} cy={12} rx={9.6} ry={4} transform="rotate(60 12 12)" />
<ellipse cx={12} cy={12} rx={9.6} ry={4} transform="rotate(120 12 12)" />
</g>
</svg>
);
}
/** The blue "open in editor" chip in the title bar. */
export function EditorChipIcon({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x={1} y={1} width={22} height={22} rx={5} fill="#2f86ff" />
<path
d="M16.4 5.6 10 12l6.4 6.4V5.6ZM8.3 9.9 5.6 12l2.7 2.1"
stroke="#fff"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function SourceControlIcon({ size = 14, className }: { size?: number; className?: string }) {
return (
<svg
@@ -1 +1,8 @@
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
export { PhoneFrame } from "./mobile/frame";
export { MobileChat } from "./mobile/chat";
export { MobileDiff } from "./mobile/diff";
export { MobileSidebar } from "./mobile/sidebar";
export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states";
export { MockupWindow } from "./window";
@@ -0,0 +1,56 @@
// Small shared bits for the mobile mockups. The Paseo butterfly mark is the
// app icon (packages/website/public/favicon.svg) with the black plate dropped,
// so it can be tinted and placed on any tile. Tile fills are the app's identity
// palette — packages/app/src/styles/identity-colors.ts — surfaced as
// `--color-mock-tile-*` tokens in styles.css.
const BUTTERFLY_D =
"M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z";
export function PaseoMark({ size = 20, className }: { size?: number; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 700 700"
fill="currentColor"
className={className}
aria-hidden="true"
>
<path transform="translate(350,350) scale(1.05) translate(-350,-350)" d={BUTTERFLY_D} />
</svg>
);
}
/** A hashed identity tile — one letter on a muted identity fill. */
export function LetterTile({
letter,
tone,
}: {
letter: string;
tone: "red" | "violet" | "amber" | "teal";
}) {
return (
<span
className={`flex size-[18px] shrink-0 items-center justify-center rounded-[5px] text-[10px] font-semibold text-white ${TILE_TONE[tone]}`}
>
{letter}
</span>
);
}
const TILE_TONE = {
red: "bg-mock-tile-red",
violet: "bg-mock-tile-violet",
amber: "bg-mock-tile-amber",
teal: "bg-mock-tile-teal",
} as const;
/** The Paseo project tile — white butterfly on the black app plate. */
export function PaseoTile() {
return (
<span className="flex size-[18px] shrink-0 items-center justify-center rounded-[5px] bg-black text-white">
<PaseoMark size={14} />
</span>
);
}
@@ -0,0 +1,157 @@
// Mobile agent chat — the counterpart of the desktop chat pane, drawn at phone
// width. Maps to the "agent chat" reference screenshot: header, collapsed slash
// command, muted tool-call rows, an assistant turn, the diff-stat pill, and the
// composer. Presentational only; colors come from the `--color-mock-*` tokens.
import {
AudioLines,
ChevronDown,
Copy,
LoaderCircle,
Menu,
Mic,
MoreHorizontal,
PanelRight,
Play,
Plus,
Server,
Split,
SquareTerminal,
} from "lucide-react";
import type * as React from "react";
import { CodexIcon } from "../icons";
const TOOL_ROWS = [
"sed -n '321,680p' docs/release.md",
'"for p in highlight relay protocol client plu…',
"npx eas workflow:view 01a0493d-01cd-77…",
"gh release view v0.7.0-beta.2 --json name…",
"gh run list --commit 4e60c2880b4c9ebd…",
"docker buildx imagetools inspect ghcr.io/g…",
];
function Header() {
return (
<div className="flex shrink-0 items-center gap-[14px] px-[18px] pt-[6px] pb-[12px]">
<Menu size={22} className="shrink-0 text-mock-fg" strokeWidth={1.9} />
<span className="min-w-0 flex-1">
<span className="block text-[18px] font-semibold leading-[21px] text-mock-fg">Paseo</span>
<span className="mt-[2px] flex items-center gap-[5px] text-[13px] leading-[16px] text-mock-fg-muted">
<span className="shrink-0">paseo ·</span>
<Server size={12} className="shrink-0" />
<span className="truncate">Mohameds-MacBook-Pro.l…</span>
</span>
</span>
<span className="flex shrink-0 items-center gap-[17px] text-mock-fg-muted">
<MoreHorizontal size={20} />
<Play size={19} strokeWidth={1.8} />
<PanelRight size={19} strokeWidth={1.8} />
</span>
</div>
);
}
function SlashRow() {
return (
<div className="flex shrink-0 items-center gap-[11px] border-y border-mock-border px-[18px] py-[13px]">
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
<span className="flex-1 text-[17px] text-mock-fg">/release-beta</span>
<ChevronDown size={19} className="shrink-0 text-mock-fg-muted" />
</div>
);
}
function ToolRow({ command }: { command: string }) {
return (
<div className="flex items-center gap-[13px] py-[7px] text-mock-fg-muted">
<SquareTerminal size={19} className="shrink-0" strokeWidth={1.7} />
<span className="shrink-0 text-[16px]">Shell</span>
<span className="min-w-0 flex-1 truncate text-[16px]">{command}</span>
</div>
);
}
function InlineCode({ children }: { children: React.ReactNode }) {
return (
<code className="rounded-[4px] bg-mock-surface2 px-[5px] py-[1px] font-mono text-[13.5px] text-mock-fg">
{children}
</code>
);
}
function Bullet({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-[12px] text-[16.5px] leading-[24px] text-mock-fg">
<span className="text-mock-fg-muted">•</span>
<span className="min-w-0 flex-1">{children}</span>
</div>
);
}
function Transcript() {
return (
<div className="px-[18px]">
{TOOL_ROWS.map((command) => (
<ToolRow key={command} command={command} />
))}
<div className="my-[14px] h-px bg-mock-border" />
<p className="text-[16.5px] leading-[24px] text-mock-fg">Still in progress:</p>
<div className="mt-[14px] space-y-[7px]">
<Bullet>npm tags and release notes are correct</Bullet>
<Bullet>
Docker beta image is published; <InlineCode>latest</InlineCode> remains unchanged
</Bullet>
<Bullet>iOS build succeeded; TestFlight distribution/Beta App Review is running</Bullet>
<Bullet>Desktop and Android assets are still building</Bullet>
</div>
<p className="mt-[14px] text-[16.5px] leading-[24px] text-mock-fg">
The heartbeat remains active.
</p>
<div className="flex items-center gap-[20px] pt-[16px] text-mock-fg-muted">
<Copy size={18} strokeWidth={1.8} />
<Split size={18} strokeWidth={1.8} />
</div>
</div>
);
}
function Composer() {
return (
<div className="shrink-0 px-[18px] pt-[10px] pb-[30px]">
<div className="mb-[16px]">
<span className="inline-flex items-center gap-[6px] rounded-full border border-mock-border-accent px-[13px] py-[6px] text-[15px] tabular-nums">
<span className="text-mock-success">+2,173</span>
<span className="text-mock-danger">-54</span>
</span>
</div>
<div className="rounded-[22px] border border-mock-border-accent bg-mock-surface1 px-[17px] pt-[15px] pb-[13px]">
<span className="block text-[16px] text-mock-fg-xmuted">Message, @files, /commands</span>
<div className="flex items-center gap-[13px] pt-[26px]">
<Plus size={20} className="shrink-0 text-mock-fg-muted" strokeWidth={1.9} />
<span className="flex shrink-0 items-center gap-[5px]">
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
<span className="whitespace-nowrap text-[16px] text-mock-fg">GPT-5.6-Sol</span>
<span className="whitespace-nowrap text-[16px] text-mock-fg-muted">Medium</span>
</span>
<span className="flex-1" />
<LoaderCircle size={19} className="shrink-0 text-mock-fg-muted" strokeWidth={1.8} />
<Mic size={19} className="shrink-0 text-mock-fg-muted" strokeWidth={1.8} />
<AudioLines size={19} className="shrink-0 text-mock-fg-muted" strokeWidth={1.8} />
</div>
</div>
</div>
);
}
export function MobileChat() {
return (
<>
<Header />
<SlashRow />
<div className="min-h-0 flex-1 overflow-hidden pt-[6px]">
<Transcript />
</div>
<Composer />
</>
);
}
@@ -0,0 +1,199 @@
// Mobile diff view — counterpart of the desktop diff pane. Maps to the "diff"
// reference screenshot: Changes/Files tabs, the branch + uncommitted scope rows,
// a new-file header, a hunk header, and the added lines of atoms.tsx (every row
// an addition, so the whole gutter runs green). Reuses the shared `Code`
// highlighter and the `--color-mock-diff-*` tokens.
import {
ChevronDown,
FileDiff,
Files,
GitBranch,
GitPullRequest,
MoreHorizontal,
RefreshCw,
SquareDot,
X,
} from "lucide-react";
import { Code } from "../atoms";
import { ReactFileIcon } from "../icons";
type MobileDiffMark = "add" | "remove" | "context";
interface MobileDiffLine {
n: number;
text: string;
mark: MobileDiffMark;
}
const ROW_COLOR: Record<MobileDiffMark, string> = {
add: "bg-mock-diff-add",
remove: "bg-mock-diff-remove",
context: "",
};
const NUMBER_COLOR: Record<MobileDiffMark, string> = {
add: "text-mock-success",
remove: "text-mock-danger",
context: "text-mock-fg-muted",
};
const DIFF_LINES: MobileDiffLine[] = [
{ n: 7, text: 'type DotTone = "success" | "danger" | "idle";', mark: "remove" },
{ n: 7, text: "export type DotTone =", mark: "add" },
{ n: 8, text: ' | "success"', mark: "add" },
{ n: 9, text: ' | "danger"', mark: "add" },
{ n: 10, text: ' | "warning"', mark: "add" },
{ n: 11, text: ' | "running"', mark: "add" },
{ n: 12, text: ' | "idle";', mark: "add" },
{ n: 13, text: "", mark: "context" },
{ n: 14, text: "interface DiffStatProps {", mark: "context" },
{ n: 15, text: " add: string;", mark: "context" },
{ n: 16, text: " remove: string;", mark: "context" },
{ n: 17, text: "}", mark: "context" },
{ n: 18, text: "", mark: "context" },
{ n: 19, text: "/** Compact diff summary. */", mark: "add" },
{ n: 20, text: "export function DiffStat({", mark: "add" },
{ n: 21, text: " add,", mark: "add" },
{ n: 22, text: " remove,", mark: "add" },
{ n: 23, text: "}: DiffStatProps) {", mark: "add" },
{ n: 24, text: " return (", mark: "context" },
{ n: 25, text: ' <span className="flex gap-1">', mark: "remove" },
{ n: 25, text: ' <span className="flex gap-[4px]">', mark: "add" },
{ n: 26, text: ' <span className="text-mock-success">', mark: "context" },
{ n: 27, text: " +{add}", mark: "context" },
{ n: 28, text: " </span>", mark: "context" },
{ n: 29, text: ' <span className="text-mock-danger">', mark: "context" },
{ n: 30, text: " -{remove}", mark: "context" },
{ n: 31, text: " </span>", mark: "context" },
{ n: 32, text: " </span>", mark: "context" },
{ n: 33, text: " )", mark: "remove" },
{ n: 33, text: " );", mark: "add" },
{ n: 34, text: "}", mark: "context" },
];
function Tabs() {
return (
<div className="flex shrink-0 items-center gap-[5px] px-[18px] pt-[4px] pb-[14px]">
<span className="flex items-center gap-[7px] rounded-[8px] px-[10px] py-[6px] text-[16px] text-mock-fg-muted">
<Files size={17} strokeWidth={1.8} />
Files
</span>
<span className="flex items-center gap-[7px] rounded-[8px] bg-mock-surface2 px-[10px] py-[6px] text-[16px] text-mock-fg">
<FileDiff size={17} strokeWidth={1.8} />
Changes
</span>
<span className="flex items-center gap-[7px] rounded-[8px] px-[10px] py-[6px] text-[16px] text-mock-fg-muted tabular-nums">
<GitPullRequest size={17} strokeWidth={1.8} />
3981
</span>
<span className="flex-1" />
<X size={22} className="text-mock-fg-muted" strokeWidth={1.9} />
</div>
);
}
function BranchRow() {
return (
<div className="flex shrink-0 items-center border-t border-mock-border px-[18px] py-[11px]">
<span className="flex items-center gap-[6px] text-[16px] text-mock-fg">
main
<ChevronDown size={16} className="text-mock-fg-muted" />
</span>
<span className="flex-1" />
<span className="flex items-center gap-[6px] rounded-[8px] border border-mock-border-accent px-[10px] py-[5px] text-mock-fg-muted">
<GitBranch size={15} />
<ChevronDown size={14} />
</span>
</div>
);
}
function ScopeRow() {
return (
<div className="flex shrink-0 items-center gap-[12px] border-y border-mock-border px-[18px] py-[11px]">
<span className="flex items-center gap-[5px] text-[16px] text-mock-fg">
Uncommitted
<ChevronDown size={16} className="text-mock-fg-muted" />
</span>
<span className="flex items-center gap-[6px] text-[15px] tabular-nums">
<span className="text-mock-success">+2,173</span>
<span className="text-mock-danger">-54</span>
</span>
<span className="flex-1" />
<span className="flex items-center gap-[15px] text-mock-fg-muted">
<RefreshCw size={17} strokeWidth={1.8} />
<MoreHorizontal size={19} />
</span>
</div>
);
}
function FileHeader() {
return (
<div className="flex shrink-0 items-center gap-[9px] px-[18px] py-[11px]">
<ReactFileIcon size={15} />
<span className="shrink-0 text-[16px] text-mock-fg">atoms.tsx</span>
<span className="min-w-0 flex-1 truncate text-[14px] text-mock-fg-muted">
packages/website/src/componen…
</span>
<span className="flex shrink-0 items-center gap-[3px] text-[14px] tabular-nums">
<span className="text-mock-success">+56</span>
<span className="text-mock-danger">-18</span>
</span>
<SquareDot size={17} className="shrink-0 text-mock-warning" strokeWidth={1.7} />
</div>
);
}
function DiffLine({ n, text, mark }: MobileDiffLine) {
const comment = text.trimStart().startsWith("/") || text.trimStart().startsWith("*");
return (
<div className={`flex items-center ${ROW_COLOR[mark]}`} style={ROW_STYLE}>
<span
className={`shrink-0 pr-[10px] text-right font-mono text-[13px] tabular-nums ${NUMBER_COLOR[mark]}`}
style={GUTTER_STYLE}
>
{n}
</span>
<span className="min-w-0 flex-1 overflow-hidden whitespace-pre pl-[12px] font-mono text-[14px] leading-[26px]">
{comment ? <span className="text-mock-fg-muted">{text}</span> : <Code line={text} />}
</span>
</div>
);
}
const ROW_H = 26;
const GUTTER_W = 44;
const ROW_STYLE = { height: ROW_H };
const GUTTER_STYLE = { width: GUTTER_W };
const GUTTER_RULE_STYLE = { left: GUTTER_W };
export function MobileDiff() {
return (
<>
<Tabs />
<BranchRow />
<ScopeRow />
<FileHeader />
<div className="relative min-h-0 flex-1 overflow-hidden bg-mock-surface1">
<div
className="pointer-events-none absolute top-0 bottom-0 w-px bg-mock-border"
style={GUTTER_RULE_STYLE}
/>
<div className="flex items-center bg-mock-surface-diff-empty" style={ROW_STYLE}>
<span className="shrink-0" style={GUTTER_STYLE} />
<span className="pl-[12px] font-mono text-[13px] text-mock-fg-muted">
@@ -7,20 +7,28 @@
</span>
</div>
{DIFF_LINES.map((line) => (
<DiffLine key={`${line.n}-${line.mark}-${line.text}`} {...line} />
))}
</div>
<div className="flex h-[52px] shrink-0 items-center border-t border-mock-border px-[18px] pb-[16px] text-[16px] text-mock-fg">
Commits
</div>
</>
);
}
@@ -0,0 +1,140 @@
// The iPhone frame every mobile mockup sits inside. Authored at a fixed
// "design" size — iPhone 17 Pro points, 402 x 874 — and scaled to whatever
// width its column gives it. The scale is pure CSS (`tan(atan2(100cqw, …))`,
// the same trick the desktop hero uses), so the frame stays crisp at any width
// with no JS and a correct server-rendered first paint.
import type * as React from "react";
export const PHONE_W = 402;
export const PHONE_H = 874;
const FRAME_ASPECT = { aspectRatio: `${PHONE_W} / ${PHONE_H}` };
const SCREEN_SCALE = {
width: PHONE_W,
height: PHONE_H,
transform: `scale(tan(atan2(100cqw, ${PHONE_W}px)))`,
};
type PhoneDepth = "front" | "left" | "right";
const SLAB_STYLE: Record<Exclude<PhoneDepth, "front">, React.CSSProperties> = {
left: { transform: "translate3d(-5px, 0, -8px)" },
right: { transform: "translate3d(5px, 0, -8px)" },
};
const CONTACT_SHADOW_STYLE: Record<PhoneDepth, React.CSSProperties> = {
front: { transform: "translate3d(0, 4px, -12px)" },
left: { transform: "translate3d(-2px, 3px, -12px)" },
right: { transform: "translate3d(2px, 3px, -12px)" },
};
const GLASS_STYLE = {
background:
"linear-gradient(122deg, rgba(255,255,255,0.048) 0%, rgba(255,255,255,0.016) 25%, transparent 43%), radial-gradient(circle at 78% 10%, rgba(255,255,255,0.026), transparent 31%)",
};
/** iOS status bar: time, dynamic island, and the signal/wifi/battery cluster. */
function StatusBar({ time }: { time: string }) {
return (
<div className="relative flex h-[54px] shrink-0 items-end justify-between px-[34px] pb-[13px]">
<span className="text-[17px] font-semibold tracking-tight text-mock-fg tabular-nums">
{time}
</span>
{/* Dynamic island. */}
<div className="absolute top-[11px] left-1/2 h-[37px] w-[126px] -translate-x-1/2 rounded-full bg-black" />
<span className="flex items-center gap-[7px] text-mock-fg">
<CellularIcon />
<WifiIcon />
<BatteryIcon />
</span>
</div>
);
}
function CellularIcon() {
return (
<svg width={18} height={12} viewBox="0 0 18 12" fill="currentColor" aria-hidden="true">
<rect x={0} y={8} width={3} height={4} rx={1} />
<rect x={5} y={5.5} width={3} height={6.5} rx={1} />
<rect x={10} y={3} width={3} height={9} rx={1} />
<rect x={15} y={0.5} width={3} height={11.5} rx={1} />
</svg>
);
}
function WifiIcon() {
return (
<svg width={17} height={12} viewBox="0 0 17 12" fill="currentColor" aria-hidden="true">
<path d="M8.5 2.1c2.7 0 5.2 1.05 7.05 2.77.3.29.31.77.02 1.07l-.72.73a.74.74 0 0 1-1.04.02A7.5 7.5 0 0 0 8.5 4.6a7.5 7.5 0 0 0-5.31 2.11.74.74 0 0 1-1.04-.02l-.72-.73a.76.76 0 0 1 .02-1.07A10.2 10.2 0 0 1 8.5 2.1Z" />
<path d="M8.5 6.2c1.5 0 2.87.57 3.9 1.5.31.29.32.78.02 1.08l-.86.86a.73.73 0 0 1-1 .04 3.1 3.1 0 0 0-4.12 0 .73.73 0 0 1-1-.04l-.86-.86a.75.75 0 0 1 .02-1.08A5.83 5.83 0 0 1 8.5 6.2Z" />
<path d="M8.5 9.9c.62 0 1.17.26 1.56.68.28.3.27.77-.02 1.06l-1 1a.76.76 0 0 1-1.08 0l-1-1a.75.75 0 0 1-.02-1.06c.39-.42.94-.68 1.56-.68Z" />
</svg>
);
}
function BatteryIcon() {
return (
<svg width={27} height={13} viewBox="0 0 27 13" fill="none" aria-hidden="true">
<rect x={0.5} y={0.5} width={23} height={12} rx={3.6} stroke="currentColor" opacity={0.4} />
<rect x={2} y={2} width={20} height={9} rx={2.2} fill="currentColor" />
<rect x={25} y={4} width={1.6} height={5} rx={0.8} fill="currentColor" opacity={0.4} />
</svg>
);
}
function HomeIndicator() {
return (
<div className="pointer-events-none absolute bottom-[9px] left-1/2 h-[5px] w-[140px] -translate-x-1/2 rounded-full bg-mock-fg/30" />
);
}
function FrontButtons() {
return (
<>
<span className="pointer-events-none absolute top-[18%] -left-[2px] h-[8%] w-[3px] rounded-l-[2px] bg-[#1b1e1d]" />
<span className="pointer-events-none absolute top-[28%] -left-[2px] h-[5%] w-[3px] rounded-l-[2px] bg-[#1b1e1d]" />
<span className="pointer-events-none absolute top-[25%] -right-[2px] h-[13%] w-[3px] rounded-r-[2px] bg-[#151716]" />
</>
);
}
export function PhoneFrame({
time,
children,
depth = "front",
}: {
time: string;
children: React.ReactNode;
depth?: PhoneDepth;
}) {
return (
<div className="relative w-full select-none [transform-style:preserve-3d]" style={FRAME_ASPECT}>
<div
className="pointer-events-none absolute right-[8%] -bottom-[0.8%] left-[8%] h-[3.5%] rounded-full bg-black/25 blur-[10px]"
style={CONTACT_SHADOW_STYLE[depth]}
/>
{depth === "front" ? (
<FrontButtons />
) : (
<div
className="pointer-events-none absolute inset-0 rounded-[13.5%/6.2%] border border-white/7 bg-[linear-gradient(180deg,#303432_0%,#1b1e1d_42%,#0b0d0c_100%)] shadow-[0_8px_22px_rgba(0,0,0,0.2)]"
style={SLAB_STYLE[depth]}
/>
)}
<div className="absolute inset-0 overflow-hidden rounded-[13.5%/6.2%] border-[3px] border-black bg-black shadow-[inset_0_0_0_1px_rgba(255,255,255,0.14)] outline outline-[1px] outline-white/25">
<div className="absolute -inset-[3px] [container-type:inline-size]">
<div className="absolute top-0 left-0 origin-top-left" style={SCREEN_SCALE}>
<div className="relative flex h-[874px] w-[402px] flex-col overflow-hidden bg-mock-surface0 text-mock-fg antialiased">
<StatusBar time={time} />
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{children}
</div>
<HomeIndicator />
</div>
</div>
</div>
<div className="pointer-events-none absolute inset-0 z-20" style={GLASS_STYLE} />
</div>
</div>
);
}
@@ -0,0 +1,314 @@
// Mobile workspace drawer — counterpart of the desktop sidebar. Maps to the
// "sidebar" reference screenshot: the New workspace / History / Schedules nav,
// the Pinned project list with identity tiles, and the Workspaces list split
// into Working and Done groups. Colors come from the `--color-mock-*` tokens.
import {
CalendarClock,
CircleCheck,
CircleDot,
CircleHelp,
Columns2,
GitPullRequest,
History,
House,
Plus,
Search,
Server,
Settings,
Settings2,
SquarePlus,
X,
} from "lucide-react";
import type * as React from "react";
import { LetterTile, PaseoTile } from "./atoms";
// Icons passed as props are hoisted so they are not rebuilt on every render.
const NEW_WORKSPACE_ICON = <Plus size={19} strokeWidth={2} />;
const NEW_WORKSPACE_CLOSE = <X size={20} className="text-mock-fg-muted" strokeWidth={1.9} />;
const HISTORY_ICON = <History size={19} strokeWidth={1.8} />;
const SCHEDULES_ICON = <CalendarClock size={19} strokeWidth={1.8} />;
const WORKING_ICON = <CircleDot size={19} className="text-mock-dot-running" strokeWidth={1.9} />;
const READY_ICON = <CircleCheck size={19} className="text-mock-success" strokeWidth={1.9} />;
const DONE_ICON = <CircleCheck size={19} className="text-mock-fg-muted" strokeWidth={1.9} />;
function NavRow({
icon,
label,
trailing,
}: {
icon: React.ReactNode;
label: string;
trailing?: React.ReactNode;
}) {
return (
<div className="flex items-center gap-[14px] px-[20px] py-[11px]">
<span className="text-mock-fg-muted">{icon}</span>
<span className="flex-1 text-[17px] text-mock-fg">{label}</span>
{trailing}
</div>
);
}
interface RowData {
key: string;
icon: React.ReactNode;
title: string;
time: string;
host?: string;
pr?: string;
passed?: boolean;
selected?: boolean;
}
/** The workspace project icon — the same filled tile as the pinned rows, with
* an optional running dot. Uses PaseoTile so pinned and workspace icons match. */
function WorkspaceGlyph({ running, white }: { running?: boolean; white?: boolean }) {
return (
<span className="relative flex size-[18px] items-center justify-center">
{white ? <span className="size-[18px] rounded-[5px] bg-mock-fg" /> : <PaseoTile />}
{running ? (
<span className="absolute -right-[1px] -bottom-[1px] size-[7px] rounded-full border-[1.5px] border-mock-surface0 bg-mock-dot-running" />
) : null}
</span>
);
}
function BlankPageGlyph({ running }: { running?: boolean }) {
return (
<span className="relative flex size-[18px] items-center justify-center">
<img src="/blank-page.svg" alt="" className="size-[18px]" />
{running ? (
<span className="absolute -right-[1px] -bottom-[1px] size-[7px] rounded-full border-[1.5px] border-mock-surface0 bg-mock-dot-running" />
) : null}
</span>
);
}
function WorkspaceMeta({ host, pr, passed }: Pick<RowData, "host" | "pr" | "passed">) {
if (!host && !pr) return null;
return (
<span className="mt-[3px] flex items-center gap-[6px] text-[12.5px] text-mock-fg-muted">
{host ? (
<span className="flex items-center gap-[4px] text-mock-host-teal">
<Server size={12} />
{host}
</span>
) : null}
{host && pr ? <span className="text-mock-fg-xmuted">·</span> : null}
{pr ? (
<span className="flex items-center gap-[4px] tabular-nums">
<GitPullRequest size={12} />
{pr}
</span>
) : null}
{passed ? (
<>
<span className="text-mock-fg-xmuted">·</span>
<span className="flex items-center gap-[4px] text-mock-success">
<CircleCheck size={13} />
passed
</span>
</>
) : null}
</span>
);
}
const PINNED: RowData[] = [
{
key: "skills-dotfiles",
icon: <LetterTile letter="S" tone="red" />,
title: "skills / dotfiles",
time: "1h",
},
];
const WORKING: RowData[] = [
{
key: "safe-area",
icon: <WorkspaceGlyph running />,
title: "Fix commit safe area in emulators",
time: "4m",
},
{
key: "blank-page-typing",
icon: <BlankPageGlyph running />,
title: "Improve editor typing latency",
time: "18m",
},
];
const READY: RowData[] = [
{
key: "paseo-homepage",
icon: <WorkspaceGlyph />,
title: "Paseo homepage",
time: "now",
pr: "3981",
passed: true,
selected: true,
},
{
key: "blank-page-titles",
icon: <BlankPageGlyph />,
title: "Add automatic page titles",
time: "12m",
pr: "761",
passed: true,
},
];
const DONE: RowData[] = [
{
key: "beta",
icon: <WorkspaceGlyph />,
title: "Release v0.7.0-beta.2",
time: "2h",
},
{
key: "blank-page-focus",
icon: <BlankPageGlyph />,
title: "Preserve focus between pages",
time: "3h",
pr: "750",
passed: true,
},
{
key: "permissions",
icon: <WorkspaceGlyph />,
title: "Design semantic permission sy…",
time: "7h",
host: "my-server",
pr: "3981",
passed: true,
},
{
key: "modules",
icon: <WorkspaceGlyph />,
title: "Plan modules and abstractions",
time: "1d",
pr: "3884",
passed: true,
},
{ key: "resume", icon: <WorkspaceGlyph />, title: "Resume in-progress work", time: "3d" },
{ key: "undefined", icon: <WorkspaceGlyph />, title: "Undefined task", time: "3d" },
{
key: "diagnose",
icon: <WorkspaceGlyph />,
title: "Diagnose cloud/Codex issue re…",
time: "4d",
},
{
key: "create-workspace",
icon: <WorkspaceGlyph />,
title: "Diagnose create_workspace 37…",
time: "4d",
},
{ key: "taps", icon: <WorkspaceGlyph />, title: "Diagnose tap interactions", time: "4d" },
{ key: "clarify", icon: <WorkspaceGlyph />, title: "Clarify task requirements", time: "5d" },
{ key: "abc", icon: <WorkspaceGlyph />, title: "Task for abcdefghi", time: "5d" },
];
/** One row — every pinned project and every workspace renders through this. */
function Row({
icon,
title,
time,
host,
pr,
passed,
selected,
indented = false,
}: RowData & { indented?: boolean }) {
return (
<div
className={`flex items-center gap-[12px] rounded-[10px] py-[9px] pr-[12px] ${
indented ? "pl-[24px]" : "pl-[12px]"
} ${selected ? "bg-mock-surface1 ring-1 ring-mock-border-accent" : ""}`}
>
<span className="flex size-[18px] shrink-0 items-center justify-center">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[16px] leading-[20px] text-mock-fg">{title}</span>
<WorkspaceMeta host={host} pr={pr} passed={passed} />
</span>
<span className="shrink-0 self-start pt-[2px] text-[13px] text-mock-fg-xmuted">{time}</span>
</div>
);
}
function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="flex items-center gap-[11px] px-[20px] pt-[16px] pb-[8px]">
{icon}
<span className="text-[15px] text-mock-fg-muted">{label}</span>
</div>
);
}
export function MobileSidebar() {
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-sidebar">
<div className="pt-[4px]">
<NavRow icon={NEW_WORKSPACE_ICON} label="New workspace" trailing={NEW_WORKSPACE_CLOSE} />
<NavRow icon={HISTORY_ICON} label="History" />
<NavRow icon={SCHEDULES_ICON} label="Schedules" />
</div>
<div className="mt-[4px] h-px bg-mock-border" />
<div className="min-h-0 flex-1 overflow-hidden">
<div className="px-[20px] pt-[16px] pb-[6px] text-[15px] text-mock-fg-muted">Pinned</div>
<div className="px-[8px]">
{PINNED.map(({ key, ...row }) => (
<Row key={key} {...row} />
))}
</div>
<div className="flex items-center px-[20px] pt-[26px] pb-[2px]">
<span className="flex-1 text-[15px] text-mock-fg-muted">Workspaces</span>
<span className="flex items-center gap-[18px] text-mock-fg-muted">
<Search size={15} strokeWidth={1.8} />
<Settings2 size={15} strokeWidth={1.8} />
</span>
</div>
<GroupHeader icon={READY_ICON} label="Ready to review" />
<div className="px-[8px]">
{READY.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
))}
</div>
<GroupHeader icon={WORKING_ICON} label="Working" />
<div className="px-[8px]">
{WORKING.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
))}
</div>
<GroupHeader icon={DONE_ICON} label="Done" />
<div className="px-[8px]">
{DONE.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
))}
</div>
</div>
<div className="h-px shrink-0 bg-mock-border" />
<div className="flex h-[64px] shrink-0 items-center px-[20px] pb-[16px] text-mock-fg-muted">
<span className="flex flex-1 items-center gap-[10px] text-[16px] text-mock-fg">
<SquarePlus size={19} strokeWidth={1.8} />
Add project
</span>
<span className="flex items-center gap-[22px]">
<Columns2 size={20} strokeWidth={1.8} />
<House size={20} strokeWidth={1.8} />
<CircleHelp size={21} strokeWidth={1.8} />
<Settings size={20} strokeWidth={1.8} />
</span>
</div>
</div>
);
}
@@ -0,0 +1,15 @@
/**
* Motion presets, hoisted to module scope because a fresh object literal in a
* JSX prop re-renders the subtree on every parent render (and the lint rule that
* enforces it).
*
* The mockup only ever cross-fades and resizes. No springs — a hero that bounces
* reads as a toy.
*/
export const FADE_IN = { opacity: 1 };
export const FADE_OUT = { opacity: 0 };
const EASE_OUT: [number, number, number, number] = [0.22, 0.61, 0.36, 1];
export const MOCKUP_TRANSITION = { duration: 0.34, ease: EASE_OUT };
export const INSTANT_TRANSITION = { duration: 0 };
@@ -0,0 +1,13 @@
import { DiffRows, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
// --- review -----------------------------------------------------------------
/** The selected Changes-tree file, open in its own workspace tab. */
export function DiffReviewPane() {
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
<ScopeRow label="Uncommitted" />
<DiffRows rows={REVIEW_DIFF_ROWS} />
</div>
);
}
@@ -0,0 +1,298 @@
import {
CircleCheck,
ChevronDown,
ExternalLink,
FileDiff,
Files,
GitPullRequest,
PanelRight,
Puzzle,
RefreshCw,
Rows3,
} from "lucide-react";
import type * as React from "react";
import { ChangesBranchRow, ChangesCommitsRow, ChangesScopeRow, ExplorerChangesTree } from "./diff";
import { RIGHT_PANEL_W, TABBAR_H, TITLEBAR_H } from "./geometry";
import type { MockupStateId } from "./states";
const TABBAR_STYLE = { height: TITLEBAR_H };
const SUBHEADER_STYLE = { height: TABBAR_H };
const PANEL_STYLE = { width: RIGHT_PANEL_W };
interface PanelTab {
label: string;
icon: React.ReactNode;
}
function PanelTabs({ tabs, active }: { tabs: PanelTab[]; active: number }) {
return (
<div
className="flex shrink-0 items-center gap-[4px] border-b border-mock-border px-[8px]"
style={TABBAR_STYLE}
>
{tabs.map((tab, index) => (
<span
key={tab.label}
className={`flex items-center gap-[6px] rounded-[6px] px-[9px] py-[3px] text-[12px] ${
index === active ? "bg-mock-surface2 text-mock-fg" : "text-mock-fg-muted"
}`}
>
{tab.icon}
{tab.label}
</span>
))}
<span className="flex-1" />
<span className="flex size-[24px] items-center justify-center text-mock-fg-muted">
<PanelRight size={14} />
</span>
</div>
);
}
function PanelShell({
tabs,
active,
children,
}: {
tabs: PanelTab[];
active: number;
children: React.ReactNode;
}) {
return (
<div
className="flex h-full shrink-0 flex-col overflow-hidden bg-mock-sidebar"
style={PANEL_STYLE}
>
<PanelTabs tabs={tabs} active={active} />
{children}
</div>
);
}
const PR_TAB_GLYPH = <GitPullRequest size={12} />;
const CHANGES_TAB_GLYPH = <FileDiff size={12} />;
const FILES_TAB_GLYPH = <Files size={12} />;
// --- Changes ---------------------------------------------------------------
const CHANGES_TABS: PanelTab[] = [
{ label: "Files", icon: FILES_TAB_GLYPH },
{ label: "Changes", icon: CHANGES_TAB_GLYPH },
{ label: "3981", icon: PR_TAB_GLYPH },
];
function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
return (
<PanelShell tabs={CHANGES_TABS} active={1}>
<ChangesBranchRow />
<ChangesScopeRow />
<ExplorerChangesTree selectedFile={selectedFile} />
<ChangesCommitsRow />
</PanelShell>
);
}
// --- Ship (GitHub PR checks) ------------------------------------------------
const SHIP_TABS: PanelTab[] = CHANGES_TABS;
const CHECKS: { name: string; context: string; time: string }[] = [
{ name: "changes", context: "CI", time: "11s" },
{ name: "setup", context: "Docker", time: "5s" },
{ name: "build", context: "Docker", time: "16m 18s" },
{ name: "format", context: "CI", time: "1m 23s" },
{ name: "build-desktop-darwin", context: "Nix", time: "6m 40s" },
{ name: "lint", context: "CI", time: "1m 43s" },
{ name: "typecheck", context: "CI", time: "2m 19s" },
{ name: "server-tests (ubuntu-latest)", context: "CI", time: "7m 15s" },
{ name: "app-tests (macos)", context: "CI", time: "5m 02s" },
];
function CheckGlyph() {
return <CircleCheck size={14} className="shrink-0 text-mock-success" />;
}
function ShipPanel() {
return (
<PanelShell tabs={SHIP_TABS} active={2}>
<div
className="flex shrink-0 items-center border-b border-mock-border px-[16px]"
style={SUBHEADER_STYLE}
>
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
<ExternalLink size={13} />
View
</span>
<span className="flex-1" />
<RefreshCw size={13} className="text-mock-fg-muted" />
</div>
<div className="px-[16px] pt-[13px] pb-[11px]">
<div className="text-[13.5px] leading-[18px] text-mock-fg">
Lay the foundation for granular daemon permissions{" "}
<span className="text-mock-fg-muted">#3981</span>
</div>
<div className="flex items-center gap-[7px] pt-[6px] text-[11.5px]">
<span className="flex items-center gap-[5px] text-mock-success">
<GitPullRequest size={12} />
Open
</span>
<span className="text-mock-fg-muted">getpaseo/paseo</span>
</div>
</div>
<div className="flex items-center gap-[11px] bg-mock-surface1 px-[16px] py-[11px]">
<CheckGlyph />
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium text-mock-fg">All checks have passed</span>
<span className="block text-[11px] text-mock-fg-muted">
9 successful, 13 skipped checks
</span>
</span>
<ChevronDown size={14} className="text-mock-fg-muted" />
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<div className="flex items-center gap-[6px] px-[16px] pt-[11px] pb-[4px] text-[11.5px] text-mock-fg-muted">
9 successful checks
<ChevronDown size={12} />
</div>
{CHECKS.map((check) => (
<div key={check.name} className="flex items-center gap-[9px] px-[16px] py-[5px]">
<CheckGlyph />
<span className="shrink-0 truncate text-[12.5px] text-mock-fg">{check.name}</span>
<span className="text-[11px] text-mock-fg-muted">{check.context}</span>
<span className="flex-1" />
<span className="shrink-0 text-[11px] tabular-nums text-mock-fg-muted">
{check.time}
</span>
</div>
))}
<div className="flex items-center gap-[6px] px-[16px] pt-[13px] pb-[4px] text-[11.5px] text-mock-fg-muted">
<ChevronDown size={12} />
Activity
</div>
<div className="px-[16px] pt-[2px] text-[12px] text-mock-fg-muted">No activity yet</div>
</div>
</PanelShell>
);
}
// --- Plugins (invented custom dashboard) ------------------------------------
// No real reference exists; this reads as a user-built native surface plugin.
const PLUGIN_TABS: PanelTab[] = [
...CHANGES_TABS,
{ label: "Release Radar", icon: <Puzzle size={12} /> },
];
const TILES: { label: string; value: string; tile: string }[] = [
{ label: "Merged · 7d", value: "12", tile: "text-mock-tile-teal" },
{ label: "In review", value: "4", tile: "text-mock-tile-amber" },
{ label: "Failing", value: "1", tile: "text-mock-tile-red" },
];
// Weekly merge counts — a small bar chart the plugin author drew. Heights and
// their style objects are precomputed so the render loop allocates nothing.
const BARS: { id: string; style: { height: string } }[] = [
{ id: "mon", style: { height: "35px" } },
{ id: "tue", style: { height: "17px" } },
{ id: "wed", style: { height: "44px" } },
{ id: "thu", style: { height: "26px" } },
{ id: "fri", style: { height: "53px" } },
{ id: "sat", style: { height: "35px" } },
{ id: "sun", style: { height: "62px" } },
];
const RECENT: { title: string; meta: string; tone: string }[] = [
{
title: "Connect to remote daemons over SSH",
meta: "#3989 · 20m ago",
tone: "bg-mock-dot-success",
},
{
title: "Keep restored workspaces out of loops",
meta: "#3987 · 1h ago",
tone: "bg-mock-dot-success",
},
{
title: "Honor configured commit signing",
meta: "#3976 · reviewing",
tone: "bg-mock-dot-warning",
},
];
function PluginDashboard() {
return (
<PanelShell tabs={PLUGIN_TABS} active={3}>
<div
className="flex shrink-0 items-center gap-[8px] border-b border-mock-border px-[16px]"
style={SUBHEADER_STYLE}
>
<Puzzle size={14} className="text-mock-accent-bright" />
<span className="text-[13px] font-medium text-mock-fg">Release Radar</span>
<span className="text-[11px] text-mock-fg-muted">plugin</span>
<span className="flex-1" />
<RefreshCw size={13} className="text-mock-fg-muted" />
</div>
<div className="min-h-0 flex-1 overflow-hidden px-[16px] pt-[14px]">
<div className="flex gap-[9px]">
{TILES.map((tile) => (
<div
key={tile.label}
className="flex-1 rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[12px] py-[11px]"
>
<div className={`text-[22px] leading-[24px] font-semibold tabular-nums ${tile.tile}`}>
{tile.value}
</div>
<div className="pt-[3px] text-[10.5px] text-mock-fg-muted">{tile.label}</div>
</div>
))}
</div>
<div className="mt-[16px] rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[14px] pt-[12px] pb-[13px]">
<div className="flex items-center justify-between text-[11px] text-mock-fg-muted">
<span>Merges · last 7 days</span>
<span className="flex items-center gap-[5px] text-mock-tile-teal">
<Rows3 size={11} />
+18%
</span>
</div>
<div className="flex h-[62px] items-end gap-[8px] pt-[12px]">
{BARS.map((bar) => (
<span
key={bar.id}
className="flex-1 rounded-[3px] bg-mock-accent-bright/70"
style={bar.style}
/>
))}
</div>
</div>
<div className="mt-[16px] text-[11px] text-mock-fg-muted">Recently merged</div>
<div className="pt-[6px]">
{RECENT.map((item) => (
<div key={item.title} className="flex items-center gap-[10px] py-[7px]">
<span className={`size-[7px] shrink-0 rounded-full ${item.tone}`} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] leading-[16px] text-mock-fg">
{item.title}
</span>
<span className="block text-[10.5px] leading-[13px] text-mock-fg-muted">
{item.meta}
</span>
</span>
</div>
))}
</div>
</div>
</PanelShell>
);
}
export function RightPanel({ state }: { state: MockupStateId }) {
if (state === "ship") return <ShipPanel />;
if (state === "extend") return <PluginDashboard />;
return <ChangesPanel />;
}
@@ -0,0 +1,348 @@
import { AnimatePresence, motion } from "framer-motion";
import {
CalendarClock,
CircleCheck,
CircleDot,
CircleHelp,
GitPullRequest,
History,
House,
PanelBottom,
PanelLeft,
Plus,
Puzzle,
Search,
Server,
Settings,
Settings2,
SquarePlus,
} from "lucide-react";
import type * as React from "react";
import { SIDEBAR_W, TITLEBAR_H } from "./geometry";
import { PaseoMark } from "./icons";
import { FADE_IN, FADE_OUT } from "./motion";
import type { MockupStateId } from "./states";
const SIDEBAR_STYLE = { width: SIDEBAR_W };
const TITLEBAR_STYLE = { height: TITLEBAR_H };
const NAV_DEFAULT_LAYOUT = { height: 97 };
const NAV_EXTENDED_LAYOUT = { height: 127 };
// --- data -------------------------------------------------------------------
type Avatar =
| { kind: "paseo" }
| { kind: "blank-page" }
| { kind: "tile"; letter: string; tile: string };
interface WorkspaceRow {
id: string;
title: string;
avatar: Avatar;
time: string;
host?: string;
pr?: string;
passed?: boolean;
running?: boolean;
selected?: boolean;
}
const PASEO: Avatar = { kind: "paseo" };
const BLANK_PAGE: Avatar = { kind: "blank-page" };
const PINNED_ROWS: WorkspaceRow[] = [
{
id: "pin-skills-dotfiles",
title: "skills / dotfiles",
avatar: { kind: "tile", letter: "S", tile: "bg-mock-tile-red" },
time: "1h",
},
];
const WORKING_ROWS: WorkspaceRow[] = [
{
id: "safe-area",
title: "Fix commit safe area in emulators",
avatar: PASEO,
time: "4m",
running: true,
},
{
id: "blank-page-typing",
title: "Improve editor typing latency",
avatar: BLANK_PAGE,
time: "18m",
running: true,
},
];
const READY_TO_REVIEW_ROWS: WorkspaceRow[] = [
{
id: "lineage",
title: "Paseo homepage",
avatar: PASEO,
pr: "3981",
passed: true,
time: "now",
selected: true,
},
{
id: "blank-page-titles",
title: "Add automatic page titles",
avatar: BLANK_PAGE,
pr: "761",
passed: true,
time: "12m",
},
];
const DONE_ROWS: WorkspaceRow[] = [
{ id: "beta", title: "Release v0.7.0-beta.2", avatar: PASEO, time: "2h" },
{
id: "blank-page-focus",
title: "Preserve focus between pages",
avatar: BLANK_PAGE,
pr: "750",
passed: true,
time: "3h",
},
{
id: "perms",
title: "Design semantic permission sy…",
avatar: PASEO,
host: "my-server",
pr: "3981",
passed: true,
time: "7h",
},
{
id: "modules",
title: "Plan modules and abstractions",
avatar: PASEO,
pr: "3884",
passed: true,
time: "1d",
},
{ id: "resume", title: "Resume in-progress work", avatar: PASEO, time: "3d" },
{ id: "undef", title: "Undefined task", avatar: PASEO, time: "3d" },
{ id: "codex-issue", title: "Diagnose cloud/Codex issue re…", avatar: PASEO, time: "4d" },
{ id: "cws", title: "Diagnose create_workspace 37…", avatar: PASEO, time: "4d" },
{ id: "taps", title: "Diagnose tap interactions", avatar: PASEO, time: "4d" },
{ id: "clarify", title: "Clarify task requirements", avatar: PASEO, time: "5d" },
{ id: "abc", title: "Task for abcdefghi", avatar: PASEO, time: "5d" },
];
// --- pieces -----------------------------------------------------------------
// Icons passed as props are hoisted so they are not rebuilt on every render.
const NEW_WORKSPACE_ICON = <Plus size={14} />;
const HISTORY_ICON = <History size={14} />;
const SCHEDULES_ICON = <CalendarClock size={14} />;
const PLUGIN_ICON = <Puzzle size={14} />;
const WORKING_ICON = <CircleDot size={16} className="text-mock-dot-running" />;
const READY_TO_REVIEW_ICON = <CircleCheck size={16} className="text-mock-success" />;
const DONE_ICON = <CircleCheck size={16} className="text-mock-fg-xmuted" />;
function NavItem({
icon,
label,
active,
}: {
icon: React.ReactNode;
label: string;
active?: boolean;
}) {
return (
<div
className={`flex h-[30px] items-center gap-[10px] rounded-[7px] px-[10px] text-[12.5px] text-mock-fg ${
active ? "bg-mock-surface1" : ""
}`}
>
<span className="text-mock-fg-muted">{icon}</span>
{label}
</div>
);
}
/** The rounded-square agent avatar, with a blue running dot on the corner. */
function AvatarIcon({ avatar }: { avatar: Avatar }) {
if (avatar.kind === "paseo") {
return (
<span className="flex size-[16px] items-center justify-center rounded-[5px] bg-black text-white">
<PaseoMark size={14} />
</span>
);
}
if (avatar.kind === "blank-page") {
return <img src="/blank-page.svg" alt="" className="size-[16px]" />;
}
return (
<span
className={`flex size-[16px] items-center justify-center rounded-[5px] text-[9px] font-semibold text-white ${avatar.tile}`}
>
{avatar.letter}
</span>
);
}
function RowAvatar({ avatar, running }: { avatar: Avatar; running?: boolean }) {
return (
<span className="relative block size-[16px] shrink-0">
<AvatarIcon avatar={avatar} />
{running ? (
<span className="absolute -right-[1px] -bottom-[1px] size-[6px] rounded-full bg-mock-dot-running ring-[1.5px] ring-mock-sidebar" />
) : null}
</span>
);
}
function RowMeta({ row }: { row: WorkspaceRow }) {
if (row.host || row.pr) {
return (
<span className="mt-[3px] flex items-center gap-[6px] text-[10.5px] leading-[13px] text-mock-fg-muted">
{row.host ? (
<span className="flex items-center gap-[4px] text-mock-host-teal">
<Server size={12} className="shrink-0" />
{row.host}
</span>
) : null}
{row.host && row.pr ? <span className="text-mock-fg-xmuted">·</span> : null}
{row.pr ? (
<span className="flex items-center gap-[4px]">
<GitPullRequest size={10} className="shrink-0" />
{row.pr}
</span>
) : null}
{row.passed ? (
<>
<span className="text-mock-fg-xmuted">·</span>
<span className="flex items-center gap-[4px] text-mock-success">
<CircleCheck size={11} className="shrink-0" />
passed
</span>
</>
) : null}
</span>
);
}
return null;
}
function Row({ row, indented = false }: { row: WorkspaceRow; indented?: boolean }) {
return (
<motion.div
layout="position"
className={`flex gap-[8px] rounded-[8px] py-[7px] pr-[9px] ${
indented ? "pl-[21px]" : "pl-[9px]"
} ${row.selected ? "bg-mock-surface1" : ""}`}
>
<span className="pt-[1px]">
<RowAvatar avatar={row.avatar} running={row.running} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-[8px]">
<span className="min-w-0 flex-1 truncate text-[13px] leading-[17px] text-mock-fg">
{row.title}
</span>
<span className="shrink-0 text-[11px] text-mock-fg-xmuted">{row.time}</span>
</span>
<RowMeta row={row} />
</span>
</motion.div>
);
}
function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="mt-[10px] mb-[3px] flex items-center gap-[8px] px-[9px] py-[7px]">
<span className="flex size-[16px] shrink-0 items-center justify-center">{icon}</span>
<span className="text-[13px] leading-[17px] text-mock-fg-muted">{label}</span>
</div>
);
}
// --- sidebar ----------------------------------------------------------------
export function Sidebar({ state }: { state: MockupStateId }) {
const extending = state === "extend";
return (
<div className="flex shrink-0 flex-col overflow-hidden bg-mock-sidebar" style={SIDEBAR_STYLE}>
{/* The sidebar is one full-height column with no header border of its own. */}
<div className="flex shrink-0 items-center gap-[8px] px-[18px]" style={TITLEBAR_STYLE}>
<div className="flex h-[24px] items-center gap-[7px]">
<i className="block size-[10px] rounded-full bg-[#ff5f57]" />
<i className="block size-[10px] rounded-full bg-[#febc2e]" />
<i className="block size-[10px] rounded-full bg-[#28c840]" />
</div>
<span className="flex size-[24px] shrink-0 items-center justify-center">
<PanelLeftGlyph />
</span>
</div>
<motion.div
initial={false}
animate={extending ? NAV_EXTENDED_LAYOUT : NAV_DEFAULT_LAYOUT}
className="overflow-hidden px-[8px] pt-[7px]"
>
<NavItem icon={NEW_WORKSPACE_ICON} label="New workspace" />
<NavItem icon={HISTORY_ICON} label="History" />
<NavItem icon={SCHEDULES_ICON} label="Schedules" />
<motion.div initial={false} animate={extending ? FADE_IN : FADE_OUT}>
<NavItem icon={PLUGIN_ICON} label="Release Radar" active />
</motion.div>
</motion.div>
<div className="mt-[9px] h-px bg-mock-border" />
<div className="min-h-0 flex-1 overflow-hidden px-[8px]">
<div className="px-[9px] pt-[8px] text-[11px] text-mock-fg-muted">Pinned</div>
{PINNED_ROWS.map((row) => (
<Row key={row.id} row={row} />
))}
<div className="mt-[16px] flex items-center justify-between px-[9px] pb-[2px]">
<span className="text-[11px] text-mock-fg-muted">Workspaces</span>
<span className="flex items-center gap-[13px] text-mock-fg-muted">
<Search size={11} className="text-mock-fg-muted" />
<Settings2 size={11} className="text-mock-fg-muted" />
</span>
</div>
<GroupHeader icon={READY_TO_REVIEW_ICON} label="Ready to review" />
{READY_TO_REVIEW_ROWS.map((row) => (
<Row key={row.id} row={row} indented />
))}
<GroupHeader icon={WORKING_ICON} label="Working" />
<AnimatePresence initial={false} mode="popLayout">
{WORKING_ROWS.map((row) => (
<motion.div key={row.id} layout initial={FADE_OUT} animate={FADE_IN} exit={FADE_OUT}>
<Row row={row} indented />
</motion.div>
))}
</AnimatePresence>
<GroupHeader icon={DONE_ICON} label="Done" />
{DONE_ROWS.map((row) => (
<Row key={row.id} row={row} indented />
))}
</div>
<div className="h-px shrink-0 bg-mock-border" />
<div className="flex h-[44px] shrink-0 items-center gap-[8px] px-[14px] text-[12.5px] text-mock-fg-muted">
<SquarePlus size={14} />
<span className="text-mock-fg">Add project</span>
<span className="flex-1" />
<PanelBottom size={14} />
<House size={14} />
<CircleHelp size={14} />
<Settings size={14} />
</div>
</div>
);
}
function PanelLeftGlyph() {
return <PanelLeft size={15} strokeWidth={1.8} className="translate-y-px text-mock-fg-muted" />;
}
@@ -0,0 +1,19 @@
/**
* The states the hero mockup can be in, in pill order. Labels are copy — edit
* them here and nothing else has to change.
*/
export const MOCKUP_STATES = [
{ id: "build", label: "Build" },
{ id: "review", label: "Review" },
{ id: "ship", label: "Ship" },
{ id: "extend", label: "Extend" },
] as const;
export type MockupStateId = (typeof MOCKUP_STATES)[number]["id"];
export const DEFAULT_MOCKUP_STATE: MockupStateId = "build";
/** Which states keep a panel docked to the right of the workspace. */
export function hasRightPanel(_state: MockupStateId): boolean {
return true;
}
@@ -0,0 +1,140 @@
import {
ChevronDown,
Columns2,
Ellipsis,
FileDiff,
GitCommitHorizontal,
Globe,
PanelRight,
Play,
Plus,
SquareTerminal,
} from "lucide-react";
import type * as React from "react";
import finderIconUrl from "../../../../app/assets/images/editor-apps/finder.png";
import { TABBAR_H, TITLEBAR_H } from "./geometry";
import { ClaudeIcon, CodexIcon, GithubGlyph } from "./icons";
import { hasRightPanel, type MockupStateId } from "./states";
const TITLEBAR_STYLE = { height: TITLEBAR_H };
const TABBAR_STYLE = { height: TABBAR_H };
/** A bordered toolbar button — the real header buttons carry an outline. */
function HeaderButton({ children }: { children: React.ReactNode }) {
return (
<span className="flex h-[28px] items-center gap-[6px] rounded-[7px] border border-mock-border-accent bg-mock-surface1 px-[8px] text-[12px] text-mock-fg">
{children}
</span>
);
}
function FinderSplitButton() {
return (
<span className="flex h-[28px] items-center overflow-hidden rounded-[7px] border border-mock-border-accent bg-mock-surface1 text-mock-fg-muted">
<span className="flex h-full items-center px-[8px]">
<img src={finderIconUrl} alt="" className="size-[16px]" />
</span>
<span className="h-full w-px bg-mock-border-accent" />
<span className="flex h-full items-center px-[7px]">
<ChevronDown size={12} />
</span>
</span>
);
}
export function TitleBar({ state }: { state: MockupStateId }) {
const shipping = state === "ship";
return (
<div
className="flex shrink-0 items-center gap-[10px] border-b border-mock-border px-[16px]"
style={TITLEBAR_STYLE}
>
<span className="truncate text-[13.5px] font-medium text-mock-fg">Paseo homepage</span>
<span className="truncate text-[12.5px] text-mock-fg-muted">paseo</span>
<Ellipsis size={14} className="shrink-0 text-mock-fg-muted" />
<span className="flex-1" />
<HeaderButton>
<Play size={12} className="text-mock-fg-muted" />
<ChevronDown size={12} className="text-mock-fg-muted" />
</HeaderButton>
<FinderSplitButton />
<HeaderButton>
{shipping ? (
<>
<GithubGlyph size={13} />
Merge PR (squash)
</>
) : (
<>
<GitCommitHorizontal size={14} className="text-mock-fg-muted" />
Commit
</>
)}
<ChevronDown size={12} className="text-mock-fg-muted" />
</HeaderButton>
{!hasRightPanel(state) ? (
<span className="flex size-[26px] items-center justify-center text-mock-fg-muted">
<PanelRight size={14} />
</span>
) : null}
</div>
);
}
interface Tab {
id: string;
label: string;
icon: React.ReactNode;
}
const WORKSPACE_TABS: Tab[] = [
{ id: "codex", label: "Codex", icon: <CodexIcon size={11} /> },
{ id: "claude", label: "Claude Code", icon: <ClaudeIcon size={11} /> },
{
id: "terminal",
label: "npm run dev",
icon: <SquareTerminal size={11} />,
},
{
id: "browser",
label: "https://localhost:3000",
icon: <Globe size={11} />,
},
];
const DIFF_TAB: Tab = { id: "diff", label: "Diff", icon: <FileDiff size={11} /> };
export function TabStrip({ state }: { state: MockupStateId }) {
const review = state === "review";
const tabs = review ? [...WORKSPACE_TABS, DIFF_TAB] : WORKSPACE_TABS;
return (
<div
className="flex shrink-0 items-center gap-[5px] border-b border-mock-border pr-[16px] pl-[7px]"
style={TABBAR_STYLE}
>
{tabs.map((tab) => (
<div
key={tab.id}
className={`flex items-center gap-[7px] rounded-[7px] px-[10px] py-[5px] ${
(review ? tab.id === "diff" : tab.id === "codex")
? "bg-mock-surface2 text-mock-fg"
: "text-mock-fg-muted"
}`}
>
<span>{tab.icon}</span>
<span className="whitespace-nowrap text-[11.5px]">{tab.label}</span>
</div>
))}
<span className="flex size-[22px] items-center justify-center text-mock-fg-muted">
<Plus size={13} />
</span>
<span className="flex-1" />
<span className="flex items-center gap-[14px] text-mock-fg-muted">
<Columns2 size={13} />
<Ellipsis size={14} />
</span>
</div>
);
}
@@ -0,0 +1,119 @@
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
import { AgentTranscript, Composer, PluginBuildTranscript } from "./chat";
import { DESIGN_HEIGHT, DESIGN_WIDTH, RIGHT_PANEL_W } from "./geometry";
import { FADE_IN, FADE_OUT, INSTANT_TRANSITION, MOCKUP_TRANSITION } from "./motion";
import { DiffReviewPane } from "./panes";
import { RightPanel } from "./right-panel";
import { Sidebar } from "./sidebar";
import { hasRightPanel, type MockupStateId } from "./states";
import { TabStrip, TitleBar } from "./titlebar";
const WINDOW_STYLE = { width: DESIGN_WIDTH, height: DESIGN_HEIGHT };
const PANEL_STYLE = { width: RIGHT_PANEL_W };
const PANEL_COLLAPSED = { width: 0, opacity: 0 };
const PANEL_OPEN = { width: RIGHT_PANEL_W + 1, opacity: 1 };
/** Which center pane a state shows. States sharing a key keep it mounted. */
function centerKey(state: MockupStateId): string {
if (state === "review") return "review";
if (state === "extend") return "extend";
return "chat";
}
function panelKey(state: MockupStateId): string {
if (state === "ship") return "ship";
if (state === "extend") return "extend";
return "changes";
}
function CenterPane({ state }: { state: MockupStateId }) {
if (state === "review") return <DiffReviewPane />;
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
{/* Bottom-anchored: the newest turn sits on the composer, older text clips. */}
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-[27px] pb-[6px]">
{state === "extend" ? <PluginBuildTranscript /> : <AgentTranscript />}
</div>
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
</div>
);
}
/** The docked panel keeps its width while its contents swap. */
function PanelCrossfade({ state }: { state: MockupStateId }) {
return (
<div className="relative" style={PANEL_STYLE}>
<AnimatePresence initial={false}>
<motion.div
key={panelKey(state)}
initial={FADE_OUT}
animate={FADE_IN}
exit={FADE_OUT}
className="absolute inset-0"
>
<RightPanel state={state} />
</motion.div>
</AnimatePresence>
</div>
);
}
/** The middle content column: its own header, then the tab strip, then the pane. */
function CenterColumn({ state }: { state: MockupStateId }) {
return (
<div className="flex min-w-0 flex-1 flex-col">
<TitleBar state={state} />
<TabStrip state={state} />
<div className="relative min-h-0 flex-1">
<AnimatePresence initial={false}>
<motion.div
key={centerKey(state)}
initial={FADE_OUT}
animate={FADE_IN}
exit={FADE_OUT}
className="absolute inset-0 flex flex-col"
>
<CenterPane state={state} />
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
/** The docked right panel, animating its width open and closed. */
function DockedPanel({ state }: { state: MockupStateId }) {
return (
<motion.div
initial={PANEL_COLLAPSED}
animate={PANEL_OPEN}
exit={PANEL_COLLAPSED}
className="relative flex shrink-0 overflow-hidden"
>
<div className="w-px shrink-0 bg-mock-border" />
<PanelCrossfade state={state} />
</motion.div>
);
}
export function MockupWindow({ state }: { state: MockupStateId }) {
const reduceMotion = useReducedMotion();
return (
<MotionConfig transition={reduceMotion ? INSTANT_TRANSITION : MOCKUP_TRANSITION}>
<div
className="relative flex select-none overflow-hidden bg-mock-surface0 text-mock-fg antialiased"
style={WINDOW_STYLE}
>
{/* Three full-height columns, each owning its own header. The middle
header stops at the divider — it never spans over the right panel. */}
<Sidebar state={state} />
<div className="w-px shrink-0 bg-mock-border" />
<CenterColumn state={state} />
<AnimatePresence initial={false}>
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
</AnimatePresence>
</div>
</MotionConfig>
);
}
@@ -1,54 +1,53 @@
---
title: Open Source Claude Desktop Alternative With Linux, Mobile, and Multi-Provider Support
description: Paseo is an open source Claude Desktop alternative for developers who want Linux, self-hosting, native mobile apps, and Claude Code alongside Codex, OpenCode, Copilot, and more.
title: Open Source Claude Desktop Alternative With Native Mobile and Multi-Provider Support
description: Paseo is an open source Claude Desktop alternative that runs on your machines without a required Paseo account, telemetry, or cloud service.
nav: Claude Desktop
order: 55
---
# Paseo vs Claude Desktop
Claude Desktop is Anthropic's desktop app for Claude. It includes Chat, Cowork, and Claude Code in one app. Claude Code runs in the desktop app on macOS and Windows.
Claude Desktop is Anthropic's app for Claude Chat, Cowork, and Claude Code on macOS, Windows, and Linux.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
![Paseo desktop and mobile app](/hero-mockup.png)
## When to pick what
## The main difference
Pick Claude Desktop if you want Anthropic's first-party app for Claude, Claude Cowork, and Claude Code, with Anthropic-managed cloud sessions and the tightest Claude account integration.
Claude Desktop is Anthropic's first-party interface for Claude and requires a Claude account. It can run Claude Code locally, over SSH, or on Anthropic's infrastructure.
Pick Paseo if you want:
Paseo is an open source control plane that runs on machines you control. It does not require a Paseo account, collect telemetry, or depend on a Paseo cloud service. Connect directly from desktop, mobile, web, or the CLI, or use the optional end-to-end encrypted relay when the daemon is behind a firewall.
- Linux alongside macOS and Windows
- A native iOS and Android app for the same agent workflow
- Claude Code, Codex, OpenCode, Copilot, Pi, and 30+ more agents in one interface
- A self-hosted daemon you can run on a laptop, VM, or dev server
- A CLI and MCP server for scripting and multi-agent workflows
- Open source you can audit and fork
Paseo does not upload or store your code. The relay cannot read your code, messages, or agent output. You can also self-host the daemon, web client, and relay.
## Architecture
Paseo runs a daemon on your machine. Desktop, web, mobile, and CLI clients connect to it over a websocket. The daemon launches Claude Code and other providers as local processes, using your installed CLIs, credentials, MCP servers, skills, and project config.
Paseo runs an independent daemon on your laptop, workstation, VM, home lab, or cloud machine. Its clients connect directly or through the optional end-to-end encrypted relay. The daemon launches your installed providers with their existing credentials, skills, MCP servers, and project configuration.
Claude Desktop is the host app. The Code tab can run Claude Code locally, connect over SSH, or run remote sessions on Anthropic infrastructure.
Claude Desktop is the Anthropic-controlled host application. Claude Code can run locally, connect over SSH, or use Anthropic-managed cloud sessions.
## Providers
Claude Desktop runs Claude Code.
Paseo runs Claude Code too, plus Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
Paseo runs Claude Code too, plus Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [all supported providers](/agents).
## Application plugins
[Paseo plugins](/docs/plugins) extend Paseo itself. They can add server behavior and native client components such as workspace panels, sidebar items, composer attachments, themes, and Command Center items across desktop, browser, iOS, and Android.
Claude Desktop does not document an application extension API for adding both server behavior and native client components.
## Desktop platforms
Claude Desktop is available on macOS and Windows. Anthropic lists Linux as not available.
Paseo ships on macOS, Linux, and Windows.
Both Claude Desktop and Paseo are available on macOS, Windows, and Linux.
## Mobile
Paseo ships native iOS and Android apps with the same agent workflow as the desktop app.
Claude has iOS and Android apps. Claude Code can be controlled from mobile through Remote Control, and Claude Desktop can pair with mobile for some workflows.
Claude has iOS and Android apps. Dispatch can start local Claude Code work through an active Claude Desktop host or start a cloud session on Anthropic's infrastructure.
## Panes
@@ -95,24 +94,25 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel
## Comparison
| | Paseo | Claude Desktop |
| ---------------------------- | --------------------------------------------------------------- | --------------------------------- |
| License | Open source (Apache-2.0) | Not published as open source |
| Desktop platforms | macOS, Linux, Windows | macOS, Windows |
| Native mobile | iOS, Android | iOS, Android Claude apps |
| Coding agents | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code |
| General chat | No | Claude Chat |
| Cloud agent | Cloud waitlist | Claude Cowork and remote sessions |
| Local execution | Yes | Yes |
| SSH remote execution | Via daemon on the remote host | Yes |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | No |
| Split panes and tabs | Yes | Yes |
| In-app terminal | Yes | Yes |
| In-app browser / preview | Yes | Yes |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | PR monitoring and merge workflows |
| CLI | Run, `--host`, ls, send, schedule, loop | Claude Code CLI |
| MCP server for orchestration | Yes | MCP support inside Claude Code |
| Self-hosted daemon | Yes | No |
| | Paseo | Claude Desktop |
| ---------------------------- | --------------------------------------------------------------- | ------------------------------------ |
| License | Open source (Apache-2.0) | Not published as open source |
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
| Mobile coding workflow | Native Paseo workspace on iOS and Android | Dispatch and Cowork in Claude mobile |
| Coding agents | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code |
| Product account required | No | Claude account |
| Cloud agent | Cloud waitlist | Claude Cowork and remote sessions |
| Required cloud connection | No | Claude account and services |
| Machines you control | Laptop, workstation, VM, server, or home lab | Local machine or SSH host |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | No |
| Split panes and tabs | Yes | Yes |
| In-app terminal | Yes | Yes |
| In-app browser / preview | Yes | Yes |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | PR monitoring and merge workflows |
| CLI | Run, `--host`, ls, send, schedule, loop | Claude Code CLI |
| Remote transport | Direct connection or end-to-end encrypted relay | Anthropic Remote and cloud services |
| Application plugins | Server code and native client components | No |
| Self-hosted control plane | Daemon, web client, and relay | No |
See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Paseo vs Conductor](/alternatives/conductor).
@@ -1,54 +1,53 @@
---
title: Open Source Codex App Alternative With Linux, Mobile, and Multi-Provider Support
description: Paseo is an open source alternative to Codex App for developers who want Linux, native mobile apps, a self-hosted daemon, and Codex alongside Claude Code, OpenCode, Copilot, and more.
title: Open Source Codex App Alternative With Native Mobile and Multi-Provider Support
description: Paseo is an open source Codex alternative that runs on your machines without a required Paseo account, telemetry, or cloud service.
nav: Codex App
order: 54
---
# Paseo vs Codex App
Codex App is OpenAI's desktop app for working with Codex threads in parallel. It runs on macOS and Windows, with local, worktree, and cloud modes.
OpenAI provides Codex through the ChatGPT desktop app on macOS, Windows, and Linux, with local, worktree, cloud, and remote workflows.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
![Paseo desktop and mobile app](/hero-mockup.png)
## When to pick what
## The main difference
Pick Codex App if you want OpenAI's first-party app for Codex, with Codex-specific features like cloud threads, appshots, image generation, and computer use on macOS.
OpenAI provides Codex through the ChatGPT desktop and mobile apps. Using those product surfaces requires an OpenAI account, and cloud work runs on OpenAI-managed infrastructure.
Pick Paseo if you want:
Paseo runs your installed Codex CLI on machines you control. It does not require a Paseo account, collect telemetry, or depend on a Paseo cloud service. Connect directly from desktop, mobile, web, or the CLI, or use the optional end-to-end encrypted relay.
- Linux alongside macOS and Windows
- A native iOS and Android app
- Codex, Claude Code, OpenCode, Copilot, Pi, and 30+ more agents in one interface
- A self-hosted daemon you can run on a laptop, VM, or dev server
- A CLI and MCP server for scripting and multi-agent workflows
- Open source you can audit and fork
Paseo does not upload or store your code. The relay cannot read your code, messages, or agent output. You can self-host the daemon, web client, and relay, and use the same control plane with Codex, Claude Code, OpenCode, Pi, ACP agents, and custom providers.
## Architecture
Paseo runs a daemon on your machine. Desktop, web, mobile, and CLI clients connect to it over a websocket. The daemon launches Codex and other providers as local processes, using your installed CLIs and credentials.
Paseo runs an independent daemon on your laptop, workstation, VM, home lab, or cloud machine. The daemon launches your installed Codex CLI with its existing credentials and configuration. Clients connect directly or through the optional end-to-end encrypted relay.
Codex App is a desktop app for Codex. It can run local and worktree threads on your computer, and cloud threads on OpenAI-managed infrastructure.
OpenAI provides local, worktree, remote, and cloud Codex workflows through its ChatGPT product surfaces. Local and remote work use a connected host, while cloud work runs on OpenAI-managed infrastructure.
## Providers
Codex App runs Codex.
Paseo runs Codex too, plus Claude Code, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
Paseo runs Codex too, plus Claude Code, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [all supported providers](/agents).
## Application plugins
[Paseo plugins](/docs/plugins) extend Paseo itself. They can add server behavior and native client components such as workspace panels, sidebar items, composer attachments, themes, and Command Center items across desktop, browser, iOS, and Android.
Codex supports skills, MCP servers, and other agent extensions. OpenAI does not document an extension API for adding server-side behavior and native interface components to the Codex application itself.
## Desktop platforms
Codex App is available on macOS and Windows. OpenAI lists Linux as not available yet.
Paseo ships on macOS, Linux, and Windows.
OpenAI's desktop app and Paseo are available on macOS, Windows, and Linux.
## Mobile
Paseo ships native iOS and Android apps with the same agent workflow as the desktop app.
Codex can be controlled remotely through OpenAI's mobile surfaces, including ChatGPT mobile remote connections. Codex App itself is a desktop app.
ChatGPT Remote can start and continue Codex chats, approve actions, and review outputs from iOS and Android through a connected desktop host.
## Worktrees and local setup
@@ -89,22 +88,23 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
## Comparison
| | Paseo | Codex App |
| ---------------------------- | --------------------------------------------------------------- | ---------------------------- |
| License | Open source (Apache-2.0) | Not published as open source |
| Desktop platforms | macOS, Linux, Windows | macOS, Windows |
| Native mobile | iOS, Android | No |
| Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex |
| Local execution | Yes | Yes |
| Cloud execution | Cloud waitlist | Yes |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | No |
| In-app terminal | Yes | Yes |
| In-app browser | Yes | Yes |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Commit, push, PR |
| CLI | Run, `--host`, ls, send, schedule, loop | Codex CLI |
| MCP server for orchestration | Yes | MCP support inside Codex |
| Voice | Dictation and realtime voice | Dictation |
| Self-hosted daemon | Yes | No |
| | Paseo | Codex App |
| ------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary |
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
| Mobile coding workflow | Native Paseo workspace on iOS and Android | ChatGPT Remote on iOS and Android |
| Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex |
| Product account required | No | OpenAI account |
| Required Paseo cloud connection | No | Not applicable |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | No |
| In-app terminal | Yes | Yes |
| In-app browser | Yes | Yes |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Commit, push, PR |
| CLI | Run, `--host`, ls, send, schedule, loop | Codex CLI |
| Remote transport | Direct connection or end-to-end encrypted relay | OpenAI Remote |
| Application plugins | Server code and native client components | No |
| Voice | Dictation and realtime voice | Dictation |
| Self-hosted control plane | Daemon, web client, and relay | No |
See also: [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Supported providers](/docs/supported-providers).
See also: [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [all supported providers](/agents).
@@ -1,42 +1,43 @@
---
title: Open Source Conductor Alternative With Linux, Windows, and Mobile
description: Paseo is open source, runs on macOS, Linux, and Windows, ships native iOS and Android apps, and supports 30+ agents through the in-app catalog plus any ACP or CLI agent. Conductor is macOS only and Claude Code or Codex only.
description: Paseo is an open source Conductor alternative with Linux, Windows, native mobile apps, a self-hosted daemon, and an extensible client.
nav: Conductor
order: 50
---
# Paseo vs Conductor
Conductor is a macOS app for running Claude Code and Codex in parallel git worktrees. Closed source.
Conductor is a proprietary macOS app for running Claude Code, Codex, Cursor, and OpenCode in parallel Git worktrees and managed cloud workspaces.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
![Paseo desktop and mobile app](/hero-mockup.png)
## Why pick Paseo
## The main difference
Conductor runs on macOS, with Claude Code and Codex, in parallel git worktrees. Paseo does all of that. Pick Paseo if you want:
Conductor provides free local workspaces on macOS. Its managed cloud workspaces, API, collaboration features, and forthcoming mobile app are included in the $50 per month Pro plan.
- Linux or Windows alongside macOS
- A native iOS and Android app
- Many more agents than Claude Code and Codex
- A CLI to script agent work and drive remote daemons
- A self-hosted daemon you can run on a server, VM, or homelab
- Open source you can audit and fork
Conductor raised a $22 million Series A and is proprietary. Paseo is independent, Apache 2.0 licensed, available on macOS, Linux, Windows, iOS, and Android, and can connect to machines you control.
## Architecture
The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI all connect to it over a websocket. Run the daemon on your laptop, on a VM, in Docker, or across a fleet, and connect to any of them from any client.
Conductor's desktop app is the host. Agents run inside it.
Conductor runs local workspaces through its macOS app and cloud workspaces in managed Vercel sandboxes. It does not currently support connecting its clients to a cloud machine you operate.
## Providers
Paseo runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
Paseo runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [all supported providers](/agents).
Conductor runs Claude Code and Codex.
Conductor supports Claude Code, Codex, Cursor, and OpenCode.
Both tools launch the official CLIs as subprocesses with your own credentials. Neither extracts tokens or proxies model calls.
Both tools use your provider credentials. Paseo launches the provider installed on your machine. Conductor bundles managed Claude Code and Codex binaries and provides managed integrations for Cursor and OpenCode.
## Application plugins
[Paseo plugins](/docs/plugins) extend Paseo itself. They can add server behavior and native client components such as workspace panels, sidebar items, composer attachments, themes, and Command Center items across desktop, browser, iOS, and Android.
Conductor does not document an application extension API for adding both server behavior and native client components.
## Panes
@@ -60,7 +61,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase"
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron.
Conductor does not have a CLI.
Conductor lists its API as a Pro feature but does not document a user-facing CLI comparable to Paseo's.
## Worktrees and services
@@ -70,28 +71,29 @@ Paseo also gives each worktree its own dev server URL. Two agents running their
## Mobile
Paseo ships native iOS and Android apps with the same feature set as the desktop app. Conductor has no mobile app.
Paseo ships native iOS and Android apps today. Conductor lists its mobile app as coming soon under the Pro plan.
## Voice
Paseo's speech-to-text and text-to-speech run locally on your device. Nothing leaves your network. Conductor does not have voice.
Paseo supports local speech-to-text and text-to-speech. Conductor does not currently document a voice interface.
## Comparison
| | Paseo | Conductor |
| ---------------------------- | --------------------------------------------------------------- | ------------------ |
| License | Open source (Apache-2.0) | Closed source |
| Platforms | macOS, Linux, Windows | macOS only |
| Native mobile | iOS, Android | — |
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code, Codex |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | — |
| Split panes and tabs | Yes | — |
| In-app terminal | Yes | Yes |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
| CLI | Run, `--host`, ls, send, schedule, loop | — |
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
| | Paseo | Conductor |
| ---------------------------- | --------------------------------------------------------------- | ------------------------------------ |
| License | Open source (Apache-2.0) | Closed source |
| Platforms | macOS, Linux, Windows | macOS only |
| Native mobile | iOS, Android | Coming soon under Pro |
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code, Codex, Cursor, OpenCode |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | — |
| Split panes and tabs | Yes | — |
| In-app terminal | Yes | Yes |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
| CLI | Run, `--host`, ls, send, schedule, loop | — |
| Application plugins | Server code and native client components | No |
| Local voice | Yes | Not documented |
| Self-hosted daemon | Yes | — |
See also: [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder).
@@ -1,42 +1,35 @@
---
title: Happy Coder Alternative With a Desktop App and Git Worktrees
description: Paseo ships a native desktop app, runs agents in isolated git worktrees, and supports 30+ agents. Happy Coder is mobile and web only, wraps the agent CLI, and supports Claude Code and Codex.
title: Happy Coder Alternative With Worktrees and Multi-Provider Support
description: Paseo adds managed worktrees, native clients across desktop and mobile, and extensible daemon and client surfaces to a remote coding-agent workflow.
nav: Happy Coder
order: 53
---
# Paseo vs Happy Coder
Happy Coder is a mobile and web client for Claude Code and Codex. It wraps the agent CLI on your laptop and syncs sessions to phone and browser over an end-to-end encrypted relay. Open source under MIT.
Happy Coder connects Claude Code and Codex sessions across its macOS, web, iOS, and Android apps. It wraps the agent CLI on your laptop and syncs sessions over an end-to-end encrypted relay. Open source under MIT.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
![Paseo desktop and mobile app](/hero-mockup.png)
## When to pick what
## The main difference
Pick Happy Coder if you want the most minimal setup. Wrap an existing Claude Code or Codex session on your laptop and check in on it from your phone.
Happy Coder focuses on connecting Claude Code and Codex sessions to its mobile, web, and macOS clients through an end-to-end encrypted relay.
Pick Paseo if you want:
- A native desktop app on macOS, Linux, and Windows
- Git worktrees for parallel agents
- Per-worktree dev server URLs
- GitHub PRs, checks, reviews, and merges in the app
- Many more agents than Claude Code and Codex
- A CLI to script agent work and drive remote daemons
Paseo supports the same two providers alongside OpenCode, Pi, ACP agents, and custom providers. It also manages workspaces, worktrees, services, pull-request review, and application plugins through the same daemon.
## Architecture
Paseo runs the agent inside its own daemon. The daemon owns the agent lifecycle, the worktree, and the dev servers. Clients connect over a websocket and drive the daemon.
Happy Coder runs the agent inside its existing CLI on your laptop and syncs the session to its mobile and web clients through an end-to-end encrypted relay.
Happy Coder runs the agent through its CLI on your laptop and syncs the session to its desktop, mobile, and web clients through an end-to-end encrypted relay.
## Panes
Paseo's app has split panes and tabs (⌘D for vertical, ⌘⇧D for horizontal). Panes include a terminal alongside your agents, a diff viewer, and a browser for testing running services.
Happy Coder does not have a desktop app.
Happy Coder's macOS app places conversations beside files, diffs, terminals, and previews.
## GitHub
@@ -48,15 +41,21 @@ Both tools ship native iOS and Android apps.
## Providers
Paseo runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
Paseo runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [all supported providers](/agents).
Happy Coder runs Claude Code and Codex.
## Application plugins
[Paseo plugins](/docs/plugins) extend Paseo itself. They can add server behavior and native client components such as workspace panels, sidebar items, composer attachments, themes, and Command Center items across desktop, browser, iOS, and Android.
Happy Coder does not document an application extension API for adding both server behavior and native client components.
## Worktrees and services
Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for the same port.
Happy Coder runs the agent in the directory you launched the CLI from.
Happy Coder can start an agent in a selected machine path, including an existing Git worktree. It does not currently create or manage the worktree lifecycle.
## CLI
@@ -72,28 +71,29 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase"
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron.
Happy Coder has a CLI to launch the wrapped session. It does not have schedules or loops.
Happy Coder has CLIs for launching wrapped sessions and for creating, sending to, monitoring, and stopping remote sessions. It does not document schedules or loops.
## Voice
Paseo's speech-to-text and text-to-speech run locally on your device. Nothing leaves your network.
Both products support voice interaction. Paseo can run speech-to-text and text-to-speech locally on the device.
## Comparison
| | Paseo | Happy Coder |
| ---------------------------- | --------------------------------------------------------------- | ---------------------- |
| License | Open source (Apache-2.0) | Open source (MIT) |
| Desktop app | macOS, Linux, Windows | — |
| Native mobile | iOS, Android | iOS, Android |
| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI |
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code, Codex |
| Split panes and tabs | Yes | — |
| In-app terminal | Yes | — |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | — |
| Git worktrees | Yes | — |
| Per-worktree dev server URLs | Yes | — |
| CLI | Run, `--host`, ls, send, schedule, loop | Launch wrapped session |
| Local voice (on-device) | Yes | — |
| | Paseo | Happy Coder |
| ---------------------------- | --------------------------------------------------------------- | --------------------------- |
| License | Open source (Apache-2.0) | Open source (MIT) |
| Desktop app | macOS, Linux, Windows | macOS |
| Native mobile | iOS, Android | iOS, Android |
| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI |
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code, Codex |
| Split workspace | Yes | Yes |
| In-app terminal | Yes | Yes |
| In-app browser / preview | Yes | Yes |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | — |
| Managed Git worktrees | Yes | Existing worktree paths |
| Per-worktree dev server URLs | Yes | — |
| CLI | Run, `--host`, ls, send, schedule, loop | Launch and control sessions |
| Application plugins | Server code and native client components | No |
| Voice | Local or configured cloud speech | Yes |
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber).
@@ -1,93 +1,76 @@
---
title: OpenChamber Alternative With Linux, Windows, and Mobile
description: Paseo ships native iOS and Android apps, runs on macOS, Linux, and Windows, and supports 30+ agents. OpenChamber is macOS only with a PWA and is built around OpenCode.
title: OpenChamber Alternative With Multi-Provider Orchestration
description: Paseo is an OpenChamber alternative for developers who want multiple agent harnesses, application plugins, and an Apache-2.0 codebase.
nav: OpenChamber
order: 52
---
# Paseo vs OpenChamber
OpenChamber is a macOS desktop app for OpenCode. Also available as a PWA. Open source under MIT.
OpenChamber is an MIT-licensed workspace built around OpenCode, with desktop, web, VS Code, server, and Capacitor mobile clients.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
Paseo orchestrates many coding-agent harnesses from desktop, mobile, web, and the CLI. Open source under Apache-2.0.
![Paseo desktop and mobile app](/hero-mockup.png)
## Why pick Paseo
## The main difference
OpenChamber runs on macOS, around OpenCode, with a phone PWA. Paseo runs OpenCode too, on macOS, and adds:
OpenChamber builds its agent workflow around OpenCode. Paseo runs OpenCode alongside Claude Code, Codex, Pi, ACP agents, and custom providers.
- Linux and Windows desktop
- A native iOS and Android app
- Many more agents than OpenCode (Claude Code, Codex, Pi, plus 30+ more via the in-app ACP catalog)
- A scriptable CLI to drive agents and connect to remote daemons
OpenChamber packages its web interface for iOS and Android with Capacitor. The interface runs inside WKWebView or Android WebView, with native integrations for notifications, secure storage, QR pairing, and widgets. Paseo's mobile client is built with React Native and distributed through the App Store and Google Play.
## Mobile
## Architecture
Paseo ships a native iOS and Android app with the same feature set as the desktop. Install from the App Store or Google Play.
Both tools can run a server on your workstation or another machine and connect from desktop, web, and mobile clients.
OpenChamber does not have a native mobile app.
## Desktop
Paseo ships on macOS, Linux, and Windows.
OpenChamber ships on macOS.
Paseo's daemon launches each provider through its own native harness or through ACP. OpenChamber manages an OpenCode server and builds its agent workflow around OpenCode sessions.
## Providers
Paseo runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
OpenChamber uses OpenCode as its agent runtime. OpenCode can connect to many model providers, and OpenChamber also offers integrations for subscriptions such as Claude.
OpenChamber is built around OpenCode.
Paseo is multi-provider at the agent-harness layer. It runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ agents through its ACP catalog and any custom CLI agent. See [all supported providers](/agents).
## Panes
## Application plugins
Paseo's app has split panes and tabs (⌘D for vertical, ⌘⇧D for horizontal). Panes include a terminal alongside your agents, a diff viewer, and a browser for testing running services.
[Paseo plugins](/docs/plugins) extend Paseo itself. They can add server behavior and native client components such as workspace panels, sidebar items, composer attachments, themes, and Command Center items across desktop, browser, iOS, and Android.
## GitHub
OpenChamber does not document an application extension API for adding both server behavior and native client components.
Paseo's app handles commit, push, opening PRs, watching checks and reviews, and merging.
## Workspaces and review
## CLI
Both tools support isolated Git worktrees, terminals, diff review, browser previews, GitHub workflows, schedules, and remote machines.
Paseo has a CLI that mirrors the app:
OpenChamber adds multi-run comparisons, Fusion, and guided changes walkthroughs. Paseo adds split panes and tabs, a native Files and Changes explorer, and a full pull-request workflow across its clients.
```bash
paseo run --provider codex "implement OAuth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
## Automation
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron.
Paseo exposes workspace and agent operations through its CLI, TypeScript SDK, and MCP server. These interfaces can create workspaces, launch agents, follow progress, send messages, and manage schedules.
OpenChamber does not have a CLI.
OpenChamber's CLI runs and manages its server, remote access, and updates. Its Agent Control Tool lets an OpenCode agent create and continue sessions, create worktrees, and manage schedules.
## Worktrees and services
## Mobile and voice
Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports.
## Voice
Paseo's speech-to-text and text-to-speech run locally on your device. OpenChamber does not have voice.
Both tools provide iOS and Android clients and support dictation and spoken replies. Paseo uses a React Native mobile interface. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView.
## Comparison
| | Paseo | OpenChamber |
| ---------------------------- | --------------------------------------------------------------- | ----------------- |
| License | Open source (Apache-2.0) | Open source (MIT) |
| Desktop platforms | macOS, Linux, Windows | macOS |
| Mobile | Native iOS, Android | PWA |
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | OpenCode |
| Split panes and tabs | Yes | — |
| In-app terminal | Yes | — |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
| CLI | Run, `--host`, ls, send, schedule, loop | — |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | — |
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
| | Paseo | OpenChamber |
| ---------------------------- | --------------------------------------------------------------- | -------------------------------- |
| License | Open source (Apache-2.0) | Open source (MIT) |
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
| Mobile implementation | React Native | Capacitor WebView |
| Agent harnesses | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | OpenCode |
| Application plugins | Server code and native client components | No |
| Split panes and tabs | Yes | Workspace views |
| In-app terminal | Yes | Yes |
| In-app browser | Yes | Yes |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Issue, PR, checks, review, merge |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | Preview and port detection |
| Automation | CLI, SDK, MCP | Server CLI, Agent Control Tool |
| Schedules | Yes | Yes |
| Voice | Local dictation and realtime voice | Dictation and spoken replies |
| Self-hosted daemon | Yes | Yes |
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs Happy Coder](/alternatives/happy-coder).
@@ -13,18 +13,11 @@ Paseo is an app for orchestrating coding agents, with native clients on desktop,
![Paseo desktop and mobile app](/hero-mockup.png)
## When to pick what
## The main difference
Pick OpenCode Desktop if you want the OpenCode app, the OpenCode terminal workflow, and OpenCode's multi-model provider system.
OpenCode connects many model providers through the OpenCode agent runtime. Paseo runs OpenCode alongside independent Claude Code, Codex, Pi, ACP, and custom agent harnesses.
Pick Paseo if you want:
- A native iOS and Android app
- OpenCode beside Claude Code, Codex, Copilot, Pi, and 30+ more agents
- First-party harnesses for Claude Code and Codex instead of one agent runtime for everything
- A self-hosted daemon you can run on a laptop, VM, or dev server
- GitHub PRs, checks, reviews, and merges in the app
- A CLI and MCP server for scripting and multi-agent workflows
OpenCode provides terminal, IDE, web, and beta desktop interfaces. Paseo adds native iOS and Android clients, managed worktrees and services, pull-request workflows, and application plugins.
## Architecture
@@ -36,11 +29,17 @@ OpenCode Desktop is the desktop app for OpenCode. OpenCode is available as a ter
OpenCode is a multi-model coding agent. It can connect to many LLM providers through its own provider system, including OpenCode Zen, local models, and API providers.
Paseo is multi-provider at the agent harness layer. It runs OpenCode, Claude Code, Codex, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
Paseo is multi-provider at the agent harness layer. It runs OpenCode, Claude Code, Codex, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [all supported providers](/agents).
## Application plugins
[Paseo plugins](/docs/plugins) extend Paseo itself. They can add server behavior and native client components such as workspace panels, sidebar items, composer attachments, themes, and Command Center items across desktop, browser, iOS, and Android.
OpenCode Desktop does not document an application extension API for adding both server behavior and native client components.
## Desktop platforms
Both tools ship on macOS, Linux, and Windows.
Paseo ships on macOS, Linux, and Windows. OpenCode provides beta desktop builds for the same platforms.
## Mobile
@@ -97,7 +96,7 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
| | Paseo | OpenCode Desktop |
| ---------------------------- | --------------------------------------------------------------- | ------------------------------- |
| License | Open source (Apache-2.0) | Open source |
| License | Open source (Apache-2.0) | Open source (MIT) |
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
| Native mobile | iOS, Android | No |
| Agent harnesses | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | OpenCode |
@@ -110,6 +109,7 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | GitHub integration |
| CLI | Run, `--host`, ls, send, schedule, loop | OpenCode CLI |
| MCP server for orchestration | Yes | MCP support inside OpenCode |
| Application plugins | Server code and native client components | No |
| Local voice | Yes | No |
| Self-hosted daemon | Yes | OpenCode server / local runtime |
@@ -1,116 +1,87 @@
---
title: Superset Alternative With Linux, Windows, and Mobile
description: Paseo is open source under an OSI license, has no login wall, ships native mobile, and runs on macOS, Linux, and Windows. Superset is source-available, macOS only, and gates the desktop app on a Superset login.
title: Open Source Superset Alternative With Native Mobile
description: Paseo is an Apache-2.0 Superset alternative that runs locally without an account and supports application plugins across its server and clients.
nav: Superset
order: 51
---
# Paseo vs Superset
Superset is a macOS desktop app for running CLI coding agents in parallel git worktrees. Source-available under the Elastic License 2.0.
Superset is a source-available desktop workspace for running CLI coding agents in parallel Git worktrees. It includes a CLI, SDK, MCP server, and remote hosts.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
Paseo orchestrates coding agents from desktop, mobile, web, and the CLI. Open source under Apache-2.0.
![Paseo desktop and mobile app](/hero-mockup.png)
## When to pick what
## The main difference
Pick Superset if you prefer a terminal-first interface where agents live inside terminal panes.
Superset provides a terminal-centered workspace for running many CLI agents in parallel Git worktrees. It requires a Superset account and GitHub sign-in, and its source is published under the Elastic License 2.0.
Pick Paseo if you want:
- An OSI-approved open source license (Apache-2.0)
- Linux or Windows
- A native mobile app
- No login wall
- A per-agent UI with modes, slash commands, and file pickers
- Free without seat limits
Paseo runs locally without an account, is licensed under Apache 2.0, provides structured interfaces for supported agent harnesses, and supports application plugins with server-side behavior and native client components.
## License
Paseo is open source under Apache-2.0. Audit it, fork it, redistribute it.
Paseo is open source under Apache-2.0. You can audit, fork, modify, and redistribute it.
Superset is source-available under the Elastic License 2.0. The source is on GitHub, but the license restricts hosting it as a service and limits redistribution.
Superset publishes its source under the Elastic License 2.0. You can use and modify it, but the license restricts offering Superset as a managed service and bypassing license-protected functionality.
## Login
## Architecture and access
Superset's desktop app shows a Superset login wall on first launch. A Superset account is required to use it.
Both tools run agents on machines you control and can connect to remote hosts.
Paseo does not require any login.
Paseo's daemon runs independently of its clients. Desktop, web, mobile, CLI, SDK, and MCP clients can connect directly, and local use does not require an account.
## Architecture
The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI clients connect to it. Run the daemon on your laptop, on a server, or in Docker, and connect from anywhere.
Superset's desktop is the host. Agents run inside it.
Superset requires a Superset account and GitHub sign-in when opening the app. Its synchronization, remote access, and team workflows use Superset's cloud services.
## Providers
Both tools support many agents. Superset is a terminal multiplexer where each agent runs inside a terminal pane. Paseo runs Claude Code, Codex, OpenCode, and Pi natively with a per-agent UI (modes, slash commands, file picker, diff viewer), plus 30+ more agents through the in-app catalog via ACP, plus any custom CLI agent. See [Supported providers](/docs/supported-providers).
Superset supports many CLI-based coding agents and lets you add custom terminal agents.
## Panes
Paseo runs Claude Code, Codex, OpenCode, and Pi through native structured harnesses, plus 30+ agents through its ACP catalog and any custom CLI agent. See [all supported providers](/agents).
Paseo's app has split panes and tabs. Panes include a diff viewer and a browser for testing running services. Agents render as native UI with modes, slash commands, and file pickers.
## Application plugins
In Superset, each agent runs inside a terminal pane.
[Paseo plugins](/docs/plugins) extend Paseo itself. They can add server behavior and native client components such as workspace panels, sidebar items, composer attachments, themes, and Command Center items across desktop, browser, iOS, and Android.
## GitHub
Superset does not document an application extension API for adding both server behavior and native client components.
Paseo's app handles commit, push, opening PRs, watching checks and reviews, and merging.
## Workspaces and review
## CLI
Both tools provide Git worktrees, split panes, terminals, diff review, an in-app browser, GitHub workflows, schedules, and remote hosts.
Paseo has a CLI that mirrors the app:
Paseo gives supported providers a structured chat interface with modes, slash commands, tool calls, and file attachments. Superset can run any CLI agent in terminal panes and adds lifecycle status for supported agents.
```bash
paseo run --provider codex "implement OAuth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports.
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron.
## Automation
Superset is a desktop app and does not have a CLI.
## Worktrees and services
Both tools isolate parallel agents in git worktrees.
Paseo also gives each worktree its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports.
## Mobile
Paseo ships native iOS and Android apps with the same feature set as the desktop. Superset does not have a mobile app.
## Voice
Paseo's speech-to-text and text-to-speech run locally on your device. Superset does not have voice.
Both tools expose workspace and agent operations through a CLI, TypeScript SDK, and MCP server. They can create workspaces, launch agents, follow progress, and manage scheduled work.
## Pricing
Paseo is free with no seat limits.
Superset is free for one seat with local workspaces only. Team features and sync start at $20 per seat per month.
Superset has a free individual tier. Team features, remote access, and integrations are part of its paid plans.
## Comparison
| | Paseo | Superset |
| ---------------------------- | ----------------------------------------------------- | -------------------------------------- |
| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) |
| Platforms | macOS, Linux, Windows | macOS only |
| Native mobile | iOS, Android | — |
| Login required | No | Yes (Superset account) |
| Pricing | Free | Free 1 seat, $20/seat/mo Pro |
| Per-agent native UI | Yes (modes, slash commands, file picker, diff viewer) | Terminal output |
| Split panes and tabs | Yes | Yes (terminals) |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | — |
| CLI | Run, `--host`, ls, send, schedule, loop | — |
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
| | Paseo | Superset |
| ---------------------------- | ---------------------------------------- | -------------------------------------- |
| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) |
| Desktop platforms | macOS, Linux, Windows | macOS, experimental Linux |
| Native mobile | iOS, Android | Coming soon |
| Account required | No | Yes, with GitHub sign-in |
| Agent harnesses | Native, ACP, and custom CLI | CLI agents |
| Application plugins | Server code and native client components | No |
| Split panes and tabs | Yes | Yes |
| In-app terminal | Yes | Yes |
| In-app browser | Yes | Yes |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | Port detection |
| Automation | CLI, SDK, MCP | CLI, SDK, MCP |
| Schedules | Yes | Yes |
| Local voice | Dictation and realtime voice | No documented voice support |
| Self-hosted daemon | Yes | Host server |
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder).
+9 -3
View File
@@ -17,12 +17,18 @@ function Home() {
<LandingPage
title={
<>
Orchestrate coding agents
The control plane
<br />
from your desk and your phone
for coding agents
</>
}
subtitle={
<>
Run any coding agent from anywhere.
<br />
Self-hosted, multi-provider, open source
</>
}
subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source."
/>
);
}

Some files were not shown because too many files have changed in this diff Show More