mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b7ef823a |
+1
-35
@@ -1,64 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0 - 2026-08-31
|
||||
## 0.7.0-beta.1 - 2026-08-27
|
||||
|
||||
### 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))
|
||||
|
||||
@@ -48,6 +48,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/source-editor.md](docs/source-editor.md) | Shared CodeMirror runtime, native bridge boundary, line-ending preservation, and embedded bundle workflow |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
|
||||
@@ -109,6 +109,7 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Source editing uses one CodeMirror runtime on web and in an offline native WebView. Its ownership and bridge constraints are documented in [docs/source-editor.md](source-editor.md).
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
+1
-1
@@ -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 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.
|
||||
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.
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
|
||||
+6
-10
@@ -17,21 +17,17 @@ 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 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.
|
||||
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.
|
||||
|
||||
## 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 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).
|
||||
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).
|
||||
|
||||
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
@@ -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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Source editor
|
||||
|
||||
The source editor has one CodeMirror runtime and two hosts:
|
||||
|
||||
- Web mounts the runtime directly into the page.
|
||||
- iOS and Android mount the same runtime from a generated, offline HTML document in a WebView.
|
||||
|
||||
Keep editor mechanics in `packages/app/src/source-editor/`. File loading, conflict handling, and
|
||||
persistence belong to the file pane's `FileEditorModel`. The source editor receives a document and
|
||||
emits edits; it does not know about workspaces or daemon RPCs.
|
||||
|
||||
## Native bridge
|
||||
|
||||
The native bridge sends compact change ranges in normalized LF coordinates. The host-side document
|
||||
mirror validates and applies them, then restores the file's original line separator before calling
|
||||
the file model. Full document replacements cross the bridge only for mount and external changes.
|
||||
|
||||
Every message carries an editor key. Ignore messages from an old key after remounting or switching
|
||||
files. Keep the bridge protocol private to this module; React components outside it use
|
||||
`SourceEditorProps`.
|
||||
|
||||
Do not expose raw CodeMirror extensions as the cross-platform API. Add named capabilities to the
|
||||
source editor contract, then implement them once in the shared runtime or explicitly in each host.
|
||||
|
||||
## Embedded bundle
|
||||
|
||||
After changing the CodeMirror runtime, bridge protocol, or WebView entry point, run:
|
||||
|
||||
```bash
|
||||
npm --prefix packages/app run build:source-editor-webview
|
||||
```
|
||||
|
||||
The generated `webview-html.ts` is committed so native builds do not need a runtime bundler or
|
||||
network access. `build:source-editor-webview:check` fails when the committed artifact is stale, and
|
||||
EAS post-install rebuilds all embedded WebViews.
|
||||
@@ -1,11 +0,0 @@
|
||||
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,11 +0,0 @@
|
||||
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,11 +0,0 @@
|
||||
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,11 +0,0 @@
|
||||
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
@@ -1 +1 @@
|
||||
sha256-YiXz9rVJxzhtKdvYsg5UhVXTaetvH0sdJ/8dyfvSGQQ=
|
||||
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36298,7 +36298,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37363,12 +37363,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/server": "0.7.0",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/server": "0.7.0-beta.1",
|
||||
"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",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/relay": "0.7.0-beta.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37629,7 +37629,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"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",
|
||||
"version": "0.7.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"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",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@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",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38833,7 +38833,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39051,17 +39051,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"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",
|
||||
"@getpaseo/highlight": "0.7.0",
|
||||
"@getpaseo/plugin": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
openPrPane,
|
||||
expectPrPaneTitle,
|
||||
expectPrPaneState,
|
||||
expectPrPaneChecks,
|
||||
expectPrPaneActivity,
|
||||
expectPrPaneCheckSummary,
|
||||
expectPrPaneActivityCount,
|
||||
} from "../support/helpers/pr-pane";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
@@ -50,6 +50,7 @@ test.describe("PR pane", () => {
|
||||
],
|
||||
},
|
||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||
{ title: "PR with no checks", state: "open" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -101,21 +102,25 @@ test.describe("PR pane", () => {
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
|
||||
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneChecks(page, {
|
||||
success: ["build-1", "build-2"],
|
||||
failure: ["deploy"],
|
||||
pending: ["security"],
|
||||
});
|
||||
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
||||
});
|
||||
|
||||
test("renders every seeded activity comment", async ({ page }) => {
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ 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.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -19,31 +20,23 @@ export async function expectPrPaneState(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectPrPaneChecks(
|
||||
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 expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
checks: { success: string[]; failure: string[]; pending: string[] },
|
||||
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
|
||||
): 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);
|
||||
}
|
||||
}
|
||||
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 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 });
|
||||
}
|
||||
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "npm run start:expo",
|
||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:embedded-webviews",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
@@ -35,7 +35,10 @@
|
||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:embedded-webviews": "npm run build:terminal-webview && npm run build:source-editor-webview",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:source-editor-webview": "node ./scripts/build-source-editor-webview-html.mjs",
|
||||
"build:source-editor-webview:check": "node ./scripts/build-source-editor-webview-html.mjs --check",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(scriptDirectory, "..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(appRoot, "src/source-editor/codemirror/webview-entry.ts");
|
||||
const output = path.join(appRoot, "src/source-editor/codemirror/webview-html.ts");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
async function resolveTsPath(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.tsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if ((await fs.stat(candidate)).isFile()) return candidate;
|
||||
} catch {
|
||||
// Try the next TypeScript resolution candidate.
|
||||
}
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const aliasPlugin = {
|
||||
name: "paseo-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, async (args) => ({
|
||||
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
plugins: [aliasPlugin],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const javascript = result.outputFiles[0]?.text;
|
||||
if (!javascript) throw new Error("source editor WebView bundle produced no JavaScript");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<style>
|
||||
html, body, #source-editor-root, #source-editor-host {
|
||||
width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden;
|
||||
}
|
||||
html, body { overscroll-behavior: none; }
|
||||
#source-editor-root, #source-editor-host { display: flex; min-width: 0; min-height: 0; }
|
||||
#source-editor-host { flex: 1; }
|
||||
* { -webkit-tap-highlight-color: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>${javascript}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/scripts/build-source-editor-webview-html.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const sourceEditorWebViewHtml =
|
||||
${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await fs.readFile(output, "utf8").catch(() => null);
|
||||
if (existing !== contents) {
|
||||
throw new Error(
|
||||
`${path.relative(repoRoot, output)} is stale. Run npm --prefix packages/app run build:source-editor-webview.`,
|
||||
);
|
||||
}
|
||||
console.log(`${path.relative(repoRoot, output)} is current (${html.length} bytes)`);
|
||||
} else {
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
}
|
||||
@@ -1,36 +1,49 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HighlightStyle } from "@getpaseo/highlight";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { SourceEditor } from "@/source-editor/source-editor";
|
||||
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
|
||||
export function FileEditorView(_props: {
|
||||
export function FileEditorView(props: {
|
||||
model: FileEditorModel;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
vimEnabled: boolean;
|
||||
theme: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
foregroundMuted: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
monoFont: string;
|
||||
codeFontSize: number;
|
||||
syntax: Record<HighlightStyle, string>;
|
||||
};
|
||||
theme: SourceEditorTheme;
|
||||
onCursorChange(position: { line: number; column: number }): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}) {
|
||||
const snapshot = useSyncExternalStore(
|
||||
props.model.subscribe,
|
||||
props.model.getSnapshot,
|
||||
props.model.getSnapshot,
|
||||
);
|
||||
const selectionTarget = useMemo(
|
||||
() =>
|
||||
props.location.lineStart
|
||||
? {
|
||||
lineStart: props.location.lineStart,
|
||||
lineEnd: props.location.lineEnd,
|
||||
revision: props.navigationRevision,
|
||||
}
|
||||
: undefined,
|
||||
[props.location.lineEnd, props.location.lineStart, props.navigationRevision],
|
||||
);
|
||||
const handleChange = useCallback((document: string) => props.model.edit(document), [props.model]);
|
||||
const handleSave = useCallback(() => void props.model.save(), [props.model]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
|
||||
</View>
|
||||
<SourceEditor
|
||||
document={snapshot.content}
|
||||
filename={props.filename}
|
||||
selectionTarget={selectionTarget}
|
||||
vimEnabled={props.vimEnabled}
|
||||
theme={props.theme}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onCursorChange={props.onCursorChange}
|
||||
onVimModeChange={props.onVimModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
|
||||
}));
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
|
||||
interface FileEditorViewProps {
|
||||
model: FileEditorModel;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
vimEnabled: boolean;
|
||||
theme: EditorVisualTheme;
|
||||
onCursorChange(position: { line: number; column: number }): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}
|
||||
|
||||
const languageCompartment = new Compartment();
|
||||
const wrappingCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
const vimCompartment = new Compartment();
|
||||
|
||||
function wrappingForFile(filename: string) {
|
||||
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
|
||||
}
|
||||
|
||||
export function FileEditorView({
|
||||
model,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
vimEnabled,
|
||||
theme,
|
||||
onCursorChange,
|
||||
onVimModeChange,
|
||||
}: FileEditorViewProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
|
||||
const onCursorChangeRef = useRef(onCursorChange);
|
||||
onCursorChangeRef.current = onCursorChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
const values = initial.current;
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
vimCompartment.of(values.vimEnabled ? vim() : []),
|
||||
...editorBaseExtensions(() => void values.model.save()),
|
||||
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
||||
wrappingCompartment.of(wrappingForFile(values.filename)),
|
||||
themeCompartment.of(editorTheme(values.theme)),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (
|
||||
update.docChanged &&
|
||||
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
|
||||
) {
|
||||
const { lineSeparator } = values.model.getSnapshot();
|
||||
values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator));
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const head = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
viewRef.current = view;
|
||||
onCursorChangeRef.current({ line: 1, column: 1 });
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const document = view.state.toText(snapshot.content);
|
||||
if (view.state.doc.eq(document)) return;
|
||||
const head = Math.min(view.state.selection.main.head, document.length);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: document },
|
||||
selection: { anchor: head },
|
||||
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
|
||||
});
|
||||
}, [snapshot.content]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(lineStart).from;
|
||||
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: [
|
||||
languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
|
||||
wrappingCompartment.reconfigure(wrappingForFile(filename)),
|
||||
],
|
||||
});
|
||||
}, [filename]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
|
||||
if (!vimEnabled) {
|
||||
onVimModeChange(null);
|
||||
return;
|
||||
}
|
||||
const cm = getCM(view);
|
||||
if (!cm) return;
|
||||
function handleModeChange(event: { mode?: string }) {
|
||||
onVimModeChange((event.mode ?? "normal").toUpperCase());
|
||||
}
|
||||
cm.on("vim-mode-change", handleModeChange);
|
||||
onVimModeChange("NORMAL");
|
||||
return () => cm.off("vim-mode-change", handleModeChange);
|
||||
}, [onVimModeChange, vimEnabled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const remoteUpdate = Annotation.define<boolean>();
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
@@ -22,7 +22,6 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useLiveFile } from "./live-file/hook";
|
||||
import { useFilePreview } from "./preview-lifecycle/hook";
|
||||
@@ -329,10 +328,7 @@ function isEditableTextFile(input: {
|
||||
supportsEditing: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
isWeb &&
|
||||
input.supportsEditing &&
|
||||
input.preview?.kind === "text" &&
|
||||
input.preview.size <= 1024 * 1024,
|
||||
input.supportsEditing && input.preview?.kind === "text" && input.preview.size <= 1024 * 1024,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation } from "./presentation";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
@@ -13,7 +13,7 @@ interface FileSourceViewProps {
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
theme: EditorVisualTheme;
|
||||
theme: SourceEditorTheme;
|
||||
tooLargeMessage: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEffect, useRef } from "react";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||
import { sourceEditorTheme } from "@/source-editor/codemirror/configuration";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { editorTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
@@ -13,7 +13,7 @@ interface FileSourceViewProps {
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
theme: EditorVisualTheme;
|
||||
theme: SourceEditorTheme;
|
||||
tooLargeMessage: string;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ function ReadonlyCodeMirror({
|
||||
languageCompartment.of(
|
||||
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||
),
|
||||
themeCompartment.of(editorTheme(values.theme)),
|
||||
themeCompartment.of(sourceEditorTheme(values.theme)),
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -97,7 +97,7 @@ function ReadonlyCodeMirror({
|
||||
viewRef.current?.dispatch({
|
||||
effects: [
|
||||
languageCompartment.reconfigure(languageFor({ filename, presentation })),
|
||||
themeCompartment.reconfigure(editorTheme(theme)),
|
||||
themeCompartment.reconfigure(sourceEditorTheme(theme)),
|
||||
],
|
||||
});
|
||||
}, [filename, presentation, theme]);
|
||||
|
||||
@@ -148,7 +148,7 @@ function CheckGroup({
|
||||
}) {
|
||||
const handlePress = useCallback(() => onToggle(group.status), [group.status, onToggle]);
|
||||
return (
|
||||
<View testID={`pr-pane-check-group-${group.status}`}>
|
||||
<View>
|
||||
<Pressable onPress={handlePress} style={styles.groupHeader} accessibilityRole="button">
|
||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||
{collapsed ? (
|
||||
|
||||
@@ -20,11 +20,6 @@ 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();
|
||||
@@ -86,47 +81,6 @@ 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,9 +13,6 @@ 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
|
||||
@@ -26,7 +23,6 @@ export async function migrateAppSettings(
|
||||
settings: AppSettings,
|
||||
storage: KeyValueStorage,
|
||||
stored?: PersistedAppSettings,
|
||||
options: { native?: boolean } = {},
|
||||
): Promise<AppSettings> {
|
||||
const migrationMarker = await readValidatedJson(
|
||||
storage,
|
||||
@@ -34,26 +30,12 @@ export async function migrateAppSettings(
|
||||
AppliedMigrationsSchema,
|
||||
);
|
||||
const applied = new Set(migrationMarker?.applied ?? []);
|
||||
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) {
|
||||
if (applied.has(STEER_DEFAULT_MIGRATION)) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
const migrated: AppSettings =
|
||||
settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings;
|
||||
if (migrated !== settings) {
|
||||
const storedSidebarRowItems = stored?.sidebarRowItems ?? {};
|
||||
await storage.setItem(
|
||||
@@ -66,6 +48,7 @@ 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 16px content default on mobile and a 15px default on web", () => {
|
||||
expect(defaultContentFontSize(true)).toBe(16);
|
||||
it("uses a 15px content default on mobile and web", () => {
|
||||
expect(defaultContentFontSize(true)).toBe(15);
|
||||
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 ? 16 : FONT_SIZE.content;
|
||||
return native ? 15 : 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, platform default 15 or 16
|
||||
contentFontSize: number; // clamped px, default 15
|
||||
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, { native: isNative });
|
||||
return await migrateAppSettings(read.settings, deps.storage, stored);
|
||||
} catch (error) {
|
||||
console.error("[AppSettings] Failed to load settings:", error);
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseSourceEditorBridgeMessage } from "./bridge-protocol";
|
||||
|
||||
describe("parseSourceEditorBridgeMessage", () => {
|
||||
test("accepts compact document changes", () => {
|
||||
expect(
|
||||
parseSourceEditorBridgeMessage(
|
||||
JSON.stringify({
|
||||
type: "change",
|
||||
editorKey: "editor-1",
|
||||
changes: [{ from: 2, to: 4, insert: "replacement" }],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "change",
|
||||
editorKey: "editor-1",
|
||||
changes: [{ from: 2, to: 4, insert: "replacement" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed bridge input", () => {
|
||||
expect(parseSourceEditorBridgeMessage("not json")).toBeNull();
|
||||
expect(
|
||||
parseSourceEditorBridgeMessage(
|
||||
JSON.stringify({ type: "change", editorKey: "editor-1", changes: [{ from: "2" }] }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
SourceChange,
|
||||
SourceEditorConfiguration,
|
||||
SourceEditorPosition,
|
||||
SourceEditorSelectionTarget,
|
||||
} from "../contract";
|
||||
|
||||
export type SourceEditorHostMessage =
|
||||
| {
|
||||
type: "mount";
|
||||
editorKey: string;
|
||||
document: string;
|
||||
configuration: SourceEditorConfiguration;
|
||||
}
|
||||
| { type: "replaceDocument"; editorKey: string; document: string }
|
||||
| { type: "configure"; editorKey: string; configuration: SourceEditorConfiguration }
|
||||
| ({ type: "reveal"; editorKey: string } & SourceEditorSelectionTarget)
|
||||
| { type: "destroy"; editorKey: string };
|
||||
|
||||
export type SourceEditorBridgeMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| { type: "ready"; editorKey: string }
|
||||
| { type: "change"; editorKey: string; changes: readonly SourceChange[] }
|
||||
| ({ type: "cursor"; editorKey: string } & SourceEditorPosition)
|
||||
| { type: "save"; editorKey: string }
|
||||
| { type: "vimMode"; editorKey: string; mode: string | null };
|
||||
|
||||
export function parseSourceEditorBridgeMessage(value: string): SourceEditorBridgeMessage | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed) || typeof parsed.type !== "string") return null;
|
||||
if (parsed.type === "bridgeReady") return { type: "bridgeReady" };
|
||||
if (typeof parsed.editorKey !== "string") return null;
|
||||
switch (parsed.type) {
|
||||
case "ready":
|
||||
case "save":
|
||||
return { type: parsed.type, editorKey: parsed.editorKey };
|
||||
case "vimMode":
|
||||
return parsed.mode === null || typeof parsed.mode === "string"
|
||||
? { type: "vimMode", editorKey: parsed.editorKey, mode: parsed.mode }
|
||||
: null;
|
||||
case "cursor":
|
||||
return isPositiveInteger(parsed.line) && isPositiveInteger(parsed.column)
|
||||
? {
|
||||
type: "cursor",
|
||||
editorKey: parsed.editorKey,
|
||||
line: parsed.line,
|
||||
column: parsed.column,
|
||||
}
|
||||
: null;
|
||||
case "change": {
|
||||
const changes = parseChanges(parsed.changes);
|
||||
return changes ? { type: "change", editorKey: parsed.editorKey, changes } : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSourceEditorHostMessage(value: string): SourceEditorHostMessage | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
typeof parsed.type !== "string" ||
|
||||
typeof parsed.editorKey !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
switch (parsed.type) {
|
||||
case "mount":
|
||||
return typeof parsed.document === "string" && isConfiguration(parsed.configuration)
|
||||
? {
|
||||
type: "mount",
|
||||
editorKey: parsed.editorKey,
|
||||
document: parsed.document,
|
||||
configuration: parsed.configuration,
|
||||
}
|
||||
: null;
|
||||
case "replaceDocument":
|
||||
return typeof parsed.document === "string"
|
||||
? { type: "replaceDocument", editorKey: parsed.editorKey, document: parsed.document }
|
||||
: null;
|
||||
case "configure":
|
||||
return isConfiguration(parsed.configuration)
|
||||
? { type: "configure", editorKey: parsed.editorKey, configuration: parsed.configuration }
|
||||
: null;
|
||||
case "reveal":
|
||||
return isPositiveInteger(parsed.lineStart) &&
|
||||
(parsed.lineEnd === undefined || isPositiveInteger(parsed.lineEnd)) &&
|
||||
Number.isInteger(parsed.revision)
|
||||
? {
|
||||
type: "reveal",
|
||||
editorKey: parsed.editorKey,
|
||||
lineStart: parsed.lineStart,
|
||||
lineEnd: parsed.lineEnd,
|
||||
revision: parsed.revision as number,
|
||||
}
|
||||
: null;
|
||||
case "destroy":
|
||||
return { type: "destroy", editorKey: parsed.editorKey };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseChanges(value: unknown): SourceChange[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const changes: SourceChange[] = [];
|
||||
for (const change of value) {
|
||||
if (
|
||||
!isRecord(change) ||
|
||||
!Number.isInteger(change.from) ||
|
||||
!Number.isInteger(change.to) ||
|
||||
typeof change.insert !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
changes.push({ from: change.from as number, to: change.to as number, insert: change.insert });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function isConfiguration(value: unknown): value is SourceEditorConfiguration {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.filename !== "string" ||
|
||||
typeof value.vimEnabled !== "boolean"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const theme = value.theme;
|
||||
return (
|
||||
isRecord(theme) &&
|
||||
(theme.colorScheme === "light" || theme.colorScheme === "dark") &&
|
||||
typeof theme.background === "string" &&
|
||||
typeof theme.foreground === "string" &&
|
||||
typeof theme.cursor === "string" &&
|
||||
typeof theme.foregroundMuted === "string" &&
|
||||
typeof theme.border === "string" &&
|
||||
typeof theme.selection === "string" &&
|
||||
typeof theme.monoFont === "string" &&
|
||||
typeof theme.codeFontSize === "number" &&
|
||||
isRecord(theme.syntax)
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
+11
-20
@@ -13,22 +13,10 @@ import {
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from "@codemirror/view";
|
||||
import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight";
|
||||
import { createCodeMirrorHighlightStyle } from "@getpaseo/highlight";
|
||||
import type { SourceEditorTheme } from "../contract";
|
||||
|
||||
export interface EditorVisualTheme {
|
||||
colorScheme: "light" | "dark";
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
foregroundMuted: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
monoFont: string;
|
||||
codeFontSize: number;
|
||||
syntax: Record<HighlightStyle, string>;
|
||||
}
|
||||
|
||||
export function editorBaseExtensions(onSave: () => void) {
|
||||
export function sourceEditorBaseExtensions(onSave: () => void) {
|
||||
return [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
@@ -47,7 +35,7 @@ export function editorBaseExtensions(onSave: () => void) {
|
||||
];
|
||||
}
|
||||
|
||||
export function editorTheme(theme: EditorVisualTheme) {
|
||||
export function sourceEditorTheme(theme: SourceEditorTheme) {
|
||||
return [
|
||||
EditorView.theme(
|
||||
{
|
||||
@@ -62,8 +50,13 @@ export function editorTheme(theme: EditorVisualTheme) {
|
||||
overflow: "auto",
|
||||
fontFamily: theme.monoFont,
|
||||
lineHeight: "1.45",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: theme.foreground,
|
||||
padding: "16px 0",
|
||||
minHeight: "100%",
|
||||
},
|
||||
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
|
||||
".cm-gutters": {
|
||||
backgroundColor: theme.background,
|
||||
@@ -75,9 +68,7 @@ export function editorTheme(theme: EditorVisualTheme) {
|
||||
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
|
||||
backgroundColor: theme.selection,
|
||||
},
|
||||
".cm-selectionBackground, ::selection": {
|
||||
backgroundColor: theme.selection,
|
||||
},
|
||||
".cm-selectionBackground, ::selection": { backgroundColor: theme.selection },
|
||||
"&.cm-focused": { outline: "none" },
|
||||
},
|
||||
{ dark: theme.colorScheme === "dark" },
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||
import { EditorView, type ViewUpdate } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import type {
|
||||
SourceChange,
|
||||
SourceEditorConfiguration,
|
||||
SourceEditorPosition,
|
||||
SourceEditorSelectionTarget,
|
||||
} from "../contract";
|
||||
import { sourceEditorBaseExtensions, sourceEditorTheme } from "./configuration";
|
||||
|
||||
interface CodeMirrorRuntimeCallbacks {
|
||||
onChange(changes: readonly SourceChange[]): void;
|
||||
onSave(): void;
|
||||
onCursorChange(position: SourceEditorPosition): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}
|
||||
|
||||
const remoteUpdate = Annotation.define<boolean>();
|
||||
|
||||
export class CodeMirrorRuntime {
|
||||
private readonly languageCompartment = new Compartment();
|
||||
private readonly wrappingCompartment = new Compartment();
|
||||
private readonly themeCompartment = new Compartment();
|
||||
private readonly vimCompartment = new Compartment();
|
||||
private view: EditorView | null = null;
|
||||
private callbacks: CodeMirrorRuntimeCallbacks | null = null;
|
||||
private vimModeListener: ((event: { mode?: string }) => void) | null = null;
|
||||
|
||||
mount(input: {
|
||||
host: HTMLElement;
|
||||
document: string;
|
||||
configuration: SourceEditorConfiguration;
|
||||
callbacks: CodeMirrorRuntimeCallbacks;
|
||||
}): void {
|
||||
this.destroy();
|
||||
this.callbacks = input.callbacks;
|
||||
this.view = new EditorView({
|
||||
parent: input.host,
|
||||
state: EditorState.create({
|
||||
doc: input.document,
|
||||
extensions: [
|
||||
this.vimCompartment.of(input.configuration.vimEnabled ? vim() : []),
|
||||
...sourceEditorBaseExtensions(() => this.callbacks?.onSave()),
|
||||
this.languageCompartment.of(languageForFile(input.configuration.filename)),
|
||||
this.wrappingCompartment.of(wrappingForFile(input.configuration.filename)),
|
||||
this.themeCompartment.of(sourceEditorTheme(input.configuration.theme)),
|
||||
EditorView.updateListener.of((update) => this.receiveUpdate(update)),
|
||||
],
|
||||
}),
|
||||
});
|
||||
this.configureVim(input.configuration.vimEnabled);
|
||||
this.reportCursor();
|
||||
}
|
||||
|
||||
replaceDocument(document: string): void {
|
||||
const view = this.view;
|
||||
if (!view || view.state.doc.toString() === document) return;
|
||||
const head = Math.min(view.state.selection.main.head, document.length);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: document },
|
||||
selection: { anchor: head },
|
||||
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
|
||||
});
|
||||
}
|
||||
|
||||
configure(configuration: SourceEditorConfiguration): void {
|
||||
const view = this.view;
|
||||
if (!view) return;
|
||||
this.removeVimModeListener();
|
||||
view.dispatch({
|
||||
effects: [
|
||||
this.languageCompartment.reconfigure(languageForFile(configuration.filename)),
|
||||
this.wrappingCompartment.reconfigure(wrappingForFile(configuration.filename)),
|
||||
this.themeCompartment.reconfigure(sourceEditorTheme(configuration.theme)),
|
||||
this.vimCompartment.reconfigure(configuration.vimEnabled ? vim() : []),
|
||||
],
|
||||
});
|
||||
this.configureVim(configuration.vimEnabled);
|
||||
}
|
||||
|
||||
reveal(target: SourceEditorSelectionTarget): void {
|
||||
const view = this.view;
|
||||
if (!view) return;
|
||||
const lineStart = Math.min(target.lineStart, view.state.doc.lines);
|
||||
const lineEnd = Math.min(target.lineEnd ?? lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(lineStart).from;
|
||||
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.removeVimModeListener();
|
||||
this.view?.destroy();
|
||||
this.view = null;
|
||||
this.callbacks = null;
|
||||
}
|
||||
|
||||
private receiveUpdate(update: ViewUpdate): void {
|
||||
if (
|
||||
update.docChanged &&
|
||||
!update.transactions.some((transaction) => transaction.annotation(remoteUpdate))
|
||||
) {
|
||||
const changes: SourceChange[] = [];
|
||||
update.changes.iterChanges((from, to, _fromNew, _toNew, inserted) => {
|
||||
changes.push({ from, to, insert: inserted.toString() });
|
||||
});
|
||||
this.callbacks?.onChange(changes);
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) this.reportCursor();
|
||||
}
|
||||
|
||||
private reportCursor(): void {
|
||||
const view = this.view;
|
||||
if (!view) return;
|
||||
const head = view.state.selection.main.head;
|
||||
const line = view.state.doc.lineAt(head);
|
||||
this.callbacks?.onCursorChange({ line: line.number, column: head - line.from + 1 });
|
||||
}
|
||||
|
||||
private configureVim(enabled: boolean): void {
|
||||
if (!enabled || !this.view) {
|
||||
this.callbacks?.onVimModeChange(null);
|
||||
return;
|
||||
}
|
||||
const cm = getCM(this.view);
|
||||
if (!cm) return;
|
||||
this.vimModeListener = (event) =>
|
||||
this.callbacks?.onVimModeChange((event.mode ?? "normal").toUpperCase());
|
||||
cm.on("vim-mode-change", this.vimModeListener);
|
||||
this.callbacks?.onVimModeChange("NORMAL");
|
||||
}
|
||||
|
||||
private removeVimModeListener(): void {
|
||||
if (!this.view || !this.vimModeListener) return;
|
||||
getCM(this.view)?.off("vim-mode-change", this.vimModeListener);
|
||||
this.vimModeListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
function languageForFile(filename: string) {
|
||||
return getLanguageForFile(filename)?.extension ?? [];
|
||||
}
|
||||
|
||||
function wrappingForFile(filename: string) {
|
||||
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { CodeMirrorRuntime } from "./runtime";
|
||||
import {
|
||||
parseSourceEditorHostMessage,
|
||||
type SourceEditorBridgeMessage,
|
||||
type SourceEditorHostMessage,
|
||||
} from "./bridge-protocol";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ReactNativeWebView?: { postMessage?: (data: string) => void };
|
||||
__PASEO_SOURCE_EDITOR_RECEIVE__?: (message: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
function send(message: SourceEditorBridgeMessage): void {
|
||||
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
|
||||
}
|
||||
|
||||
class SourceEditorWebViewBridge {
|
||||
private runtime: CodeMirrorRuntime | null = null;
|
||||
private editorKey: string | null = null;
|
||||
|
||||
receive = (rawMessage: string): void => {
|
||||
const message = parseSourceEditorHostMessage(rawMessage);
|
||||
if (!message) return;
|
||||
if (message.type === "mount") {
|
||||
this.mount(message);
|
||||
return;
|
||||
}
|
||||
if (message.editorKey !== this.editorKey) return;
|
||||
switch (message.type) {
|
||||
case "replaceDocument":
|
||||
this.runtime?.replaceDocument(message.document);
|
||||
break;
|
||||
case "configure":
|
||||
this.runtime?.configure(message.configuration);
|
||||
break;
|
||||
case "reveal":
|
||||
this.runtime?.reveal(message);
|
||||
break;
|
||||
case "destroy":
|
||||
this.destroy();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private mount(message: Extract<SourceEditorHostMessage, { type: "mount" }>): void {
|
||||
this.destroy();
|
||||
this.editorKey = message.editorKey;
|
||||
const runtime = new CodeMirrorRuntime();
|
||||
this.runtime = runtime;
|
||||
runtime.mount({
|
||||
host,
|
||||
document: message.document,
|
||||
configuration: message.configuration,
|
||||
callbacks: {
|
||||
onChange: (changes) => send({ type: "change", editorKey: message.editorKey, changes }),
|
||||
onSave: () => send({ type: "save", editorKey: message.editorKey }),
|
||||
onCursorChange: (position) =>
|
||||
send({ type: "cursor", editorKey: message.editorKey, ...position }),
|
||||
onVimModeChange: (mode) => send({ type: "vimMode", editorKey: message.editorKey, mode }),
|
||||
},
|
||||
});
|
||||
send({ type: "ready", editorKey: message.editorKey });
|
||||
}
|
||||
|
||||
private destroy(): void {
|
||||
this.runtime?.destroy();
|
||||
this.runtime = null;
|
||||
this.editorKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "source-editor-root";
|
||||
const host = document.createElement("div");
|
||||
host.id = "source-editor-host";
|
||||
root.appendChild(host);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const bridge = new SourceEditorWebViewBridge();
|
||||
window.__PASEO_SOURCE_EDITOR_RECEIVE__ = bridge.receive;
|
||||
send({ type: "bridgeReady" });
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
|
||||
import type { HighlightStyle } from "@getpaseo/highlight";
|
||||
|
||||
export interface SourceChange {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
export interface SourceEditorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface SourceEditorSelectionTarget {
|
||||
lineStart: number;
|
||||
lineEnd?: number;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface SourceEditorTheme {
|
||||
colorScheme: "light" | "dark";
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
foregroundMuted: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
monoFont: string;
|
||||
codeFontSize: number;
|
||||
syntax: Record<HighlightStyle, string>;
|
||||
}
|
||||
|
||||
export interface SourceEditorConfiguration {
|
||||
filename: string;
|
||||
vimEnabled: boolean;
|
||||
theme: SourceEditorTheme;
|
||||
}
|
||||
|
||||
export interface SourceEditorProps extends SourceEditorConfiguration {
|
||||
document: string;
|
||||
selectionTarget?: SourceEditorSelectionTarget;
|
||||
onChange(document: string): void;
|
||||
onSave(): void;
|
||||
onCursorChange(position: SourceEditorPosition): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SourceEditorController } from "./controller";
|
||||
|
||||
function makeController() {
|
||||
const events: unknown[] = [];
|
||||
const controller = new SourceEditorController({
|
||||
editorKey: "current",
|
||||
document: "one\r\ntwo",
|
||||
callbacks: {
|
||||
onChange: (document) => events.push(["change", document]),
|
||||
onSave: () => events.push(["save"]),
|
||||
onCursorChange: (position) => events.push(["cursor", position]),
|
||||
onVimModeChange: (mode) => events.push(["vim", mode]),
|
||||
},
|
||||
});
|
||||
return { controller, events };
|
||||
}
|
||||
|
||||
describe("SourceEditorController", () => {
|
||||
test("turns compact runtime changes into a document callback", () => {
|
||||
const { controller, events } = makeController();
|
||||
|
||||
expect(
|
||||
controller.receive({
|
||||
type: "change",
|
||||
editorKey: "current",
|
||||
changes: [{ from: 4, to: 7, insert: "second" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(events).toEqual([["change", "one\r\nsecond"]]);
|
||||
});
|
||||
|
||||
test("rejects messages from a stale editor generation", () => {
|
||||
const { controller, events } = makeController();
|
||||
|
||||
expect(controller.receive({ type: "save", editorKey: "stale" })).toBe(false);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects invalid changes at the document boundary", () => {
|
||||
const { controller, events } = makeController();
|
||||
|
||||
expect(
|
||||
controller.receive({
|
||||
type: "change",
|
||||
editorKey: "current",
|
||||
changes: [{ from: 0, to: 99, insert: "bad" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns a normalized replacement only when the controlled document changed", () => {
|
||||
const { controller } = makeController();
|
||||
|
||||
expect(controller.replaceDocument("one\r\ntwo")).toBeNull();
|
||||
expect(controller.replaceDocument("replacement\r\ndocument")).toBe("replacement\ndocument");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SourceEditorBridgeMessage } from "./codemirror/bridge-protocol";
|
||||
import type { SourceEditorPosition } from "./contract";
|
||||
import { SourceDocumentMirror } from "./document-mirror";
|
||||
|
||||
interface SourceEditorControllerCallbacks {
|
||||
onChange(document: string): void;
|
||||
onSave(): void;
|
||||
onCursorChange(position: SourceEditorPosition): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}
|
||||
|
||||
export class SourceEditorController {
|
||||
private readonly editorKey: string;
|
||||
private readonly mirror: SourceDocumentMirror;
|
||||
private readonly callbacks: SourceEditorControllerCallbacks;
|
||||
|
||||
constructor(input: {
|
||||
editorKey: string;
|
||||
document: string;
|
||||
callbacks: SourceEditorControllerCallbacks;
|
||||
}) {
|
||||
this.editorKey = input.editorKey;
|
||||
this.mirror = new SourceDocumentMirror(input.document);
|
||||
this.callbacks = input.callbacks;
|
||||
}
|
||||
|
||||
getRuntimeDocument(): string {
|
||||
return this.mirror.getRuntimeDocument();
|
||||
}
|
||||
|
||||
replaceDocument(document: string): string | null {
|
||||
return this.mirror.replace(document) ? this.mirror.getRuntimeDocument() : null;
|
||||
}
|
||||
|
||||
receive(message: SourceEditorBridgeMessage): boolean {
|
||||
if (message.type === "bridgeReady" || message.editorKey !== this.editorKey) return false;
|
||||
switch (message.type) {
|
||||
case "ready":
|
||||
return true;
|
||||
case "change": {
|
||||
let document: string;
|
||||
try {
|
||||
document = this.mirror.apply(message.changes);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
this.callbacks.onChange(document);
|
||||
return true;
|
||||
}
|
||||
case "cursor":
|
||||
this.callbacks.onCursorChange({ line: message.line, column: message.column });
|
||||
return true;
|
||||
case "save":
|
||||
this.callbacks.onSave();
|
||||
return true;
|
||||
case "vimMode":
|
||||
this.callbacks.onVimModeChange(message.mode);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SourceDocumentMirror } from "./document-mirror";
|
||||
|
||||
describe("SourceDocumentMirror", () => {
|
||||
test("applies CodeMirror changes without replacing unchanged text", () => {
|
||||
const mirror = new SourceDocumentMirror("one two three");
|
||||
|
||||
const document = mirror.apply([
|
||||
{ from: 0, to: 3, insert: "ONE" },
|
||||
{ from: 8, to: 13, insert: "THREE" },
|
||||
]);
|
||||
|
||||
expect(document).toBe("ONE two THREE");
|
||||
});
|
||||
|
||||
test("preserves the file line separator while runtime offsets stay normalized", () => {
|
||||
const mirror = new SourceDocumentMirror("one\r\ntwo\r\n");
|
||||
|
||||
expect(mirror.getRuntimeDocument()).toBe("one\ntwo\n");
|
||||
expect(mirror.apply([{ from: 4, to: 7, insert: "second\nline" }])).toBe(
|
||||
"one\r\nsecond\r\nline\r\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects overlapping or out-of-range changes", () => {
|
||||
const mirror = new SourceDocumentMirror("one two");
|
||||
|
||||
expect(() =>
|
||||
mirror.apply([
|
||||
{ from: 0, to: 4, insert: "" },
|
||||
{ from: 3, to: 7, insert: "" },
|
||||
]),
|
||||
).toThrow("Invalid source editor change range");
|
||||
expect(() => mirror.apply([{ from: 0, to: 8, insert: "" }])).toThrow(
|
||||
"Invalid source editor change range",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { SourceChange } from "./contract";
|
||||
|
||||
type LineSeparator = "\n" | "\r\n" | "\r";
|
||||
|
||||
export class SourceDocumentMirror {
|
||||
private text: string;
|
||||
private lineSeparator: LineSeparator;
|
||||
|
||||
constructor(document: string) {
|
||||
this.lineSeparator = detectLineSeparator(document);
|
||||
this.text = normalizeLineSeparators(document);
|
||||
}
|
||||
|
||||
getRuntimeDocument(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
getDocument(): string {
|
||||
return serializeDocument(this.text, this.lineSeparator);
|
||||
}
|
||||
|
||||
replace(document: string): boolean {
|
||||
if (document === this.getDocument()) return false;
|
||||
this.lineSeparator = detectLineSeparator(document);
|
||||
this.text = normalizeLineSeparators(document);
|
||||
return true;
|
||||
}
|
||||
|
||||
apply(changes: readonly SourceChange[]): string {
|
||||
validateChanges(changes, this.text.length);
|
||||
for (let index = changes.length - 1; index >= 0; index -= 1) {
|
||||
const change = changes[index];
|
||||
this.text = `${this.text.slice(0, change.from)}${normalizeLineSeparators(change.insert)}${this.text.slice(change.to)}`;
|
||||
}
|
||||
return this.getDocument();
|
||||
}
|
||||
}
|
||||
|
||||
function validateChanges(changes: readonly SourceChange[], documentLength: number): void {
|
||||
let previousEnd = 0;
|
||||
for (const change of changes) {
|
||||
if (
|
||||
!Number.isInteger(change.from) ||
|
||||
!Number.isInteger(change.to) ||
|
||||
change.from < previousEnd ||
|
||||
change.from > change.to ||
|
||||
change.to > documentLength
|
||||
) {
|
||||
throw new Error("Invalid source editor change range");
|
||||
}
|
||||
previousEnd = change.to;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLineSeparators(document: string): string {
|
||||
return document.replace(/\r\n?|\n/g, "\n");
|
||||
}
|
||||
|
||||
function serializeDocument(document: string, lineSeparator: LineSeparator): string {
|
||||
return lineSeparator === "\n" ? document : document.replace(/\n/g, lineSeparator);
|
||||
}
|
||||
|
||||
function detectLineSeparator(document: string): LineSeparator {
|
||||
const match = /\r\n|\r|\n/.exec(document);
|
||||
return (match?.[0] as LineSeparator | undefined) ?? "\n";
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { WebViewMessageEvent } from "react-native-webview";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SourceEditorController } from "./controller";
|
||||
import type { SourceEditorConfiguration, SourceEditorProps } from "./contract";
|
||||
import {
|
||||
parseSourceEditorBridgeMessage,
|
||||
type SourceEditorHostMessage,
|
||||
} from "./codemirror/bridge-protocol";
|
||||
import { sourceEditorWebViewHtml } from "./codemirror/webview-html";
|
||||
|
||||
const BASE_URL = "about:blank";
|
||||
const ORIGIN_WHITELIST = ["*"];
|
||||
let nextEditorKey = 1;
|
||||
|
||||
export function SourceEditor(props: SourceEditorProps) {
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const bridgeReadyRef = useRef(false);
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
const [editorKey] = useState(() => `native-source-editor-${nextEditorKey++}`);
|
||||
const controllerRef = useRef<SourceEditorController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new SourceEditorController({
|
||||
editorKey,
|
||||
document: props.document,
|
||||
callbacks: {
|
||||
onChange: (document) => propsRef.current.onChange(document),
|
||||
onSave: () => propsRef.current.onSave(),
|
||||
onCursorChange: (position) => propsRef.current.onCursorChange(position),
|
||||
onVimModeChange: (mode) => propsRef.current.onVimModeChange(mode),
|
||||
},
|
||||
});
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
const source = useMemo(() => ({ html: sourceEditorWebViewHtml, baseUrl: BASE_URL }), []);
|
||||
const configuration = useMemo<SourceEditorConfiguration>(
|
||||
() => ({ filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme }),
|
||||
[props.filename, props.theme, props.vimEnabled],
|
||||
);
|
||||
|
||||
const send = useCallback((message: SourceEditorHostMessage) => {
|
||||
if (!bridgeReadyRef.current) return;
|
||||
const serialized = JSON.stringify(JSON.stringify(message));
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`window.__PASEO_SOURCE_EDITOR_RECEIVE__?.(${serialized}); true;`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const mount = useCallback(() => {
|
||||
send({
|
||||
type: "mount",
|
||||
editorKey,
|
||||
document: controller.getRuntimeDocument(),
|
||||
configuration: configurationFromProps(propsRef.current),
|
||||
});
|
||||
}, [controller, editorKey, send]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
const message = parseSourceEditorBridgeMessage(event.nativeEvent.data);
|
||||
if (!message) return;
|
||||
if (message.type === "bridgeReady") {
|
||||
bridgeReadyRef.current = true;
|
||||
mount();
|
||||
const selectionTarget = propsRef.current.selectionTarget;
|
||||
if (selectionTarget) send({ type: "reveal", editorKey, ...selectionTarget });
|
||||
return;
|
||||
}
|
||||
controller.receive(message);
|
||||
},
|
||||
[controller, editorKey, mount, send],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const replacement = controller.replaceDocument(props.document);
|
||||
if (replacement !== null) {
|
||||
send({ type: "replaceDocument", editorKey, document: replacement });
|
||||
}
|
||||
}, [controller, editorKey, props.document, send]);
|
||||
|
||||
useEffect(() => {
|
||||
send({
|
||||
type: "configure",
|
||||
editorKey,
|
||||
configuration,
|
||||
});
|
||||
}, [configuration, editorKey, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.selectionTarget) {
|
||||
send({ type: "reveal", editorKey, ...props.selectionTarget });
|
||||
}
|
||||
}, [editorKey, props.selectionTarget, send]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
send({ type: "destroy", editorKey });
|
||||
bridgeReadyRef.current = false;
|
||||
},
|
||||
[editorKey, send],
|
||||
);
|
||||
|
||||
const allowOnlyInitialDocument = useCallback(({ url }: { url: string }) => {
|
||||
return url === BASE_URL;
|
||||
}, []);
|
||||
|
||||
const reloadAfterProcessFailure = useCallback(() => {
|
||||
bridgeReadyRef.current = false;
|
||||
webViewRef.current?.reload();
|
||||
}, []);
|
||||
const handleLoadStart = useCallback(() => {
|
||||
bridgeReadyRef.current = false;
|
||||
}, []);
|
||||
const handleRenderProcessGone = useCallback(() => {
|
||||
reloadAfterProcessFailure();
|
||||
return true;
|
||||
}, [reloadAfterProcessFailure]);
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
testID="file-source-editor"
|
||||
style={[styles.webview, { backgroundColor: props.theme.background }]}
|
||||
source={source}
|
||||
originWhitelist={ORIGIN_WHITELIST}
|
||||
onShouldStartLoadWithRequest={allowOnlyInitialDocument}
|
||||
onMessage={handleMessage}
|
||||
onLoadStart={handleLoadStart}
|
||||
onContentProcessDidTerminate={reloadAfterProcessFailure}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
setSupportMultipleWindows={false}
|
||||
javaScriptCanOpenWindowsAutomatically={false}
|
||||
domStorageEnabled={false}
|
||||
thirdPartyCookiesEnabled={false}
|
||||
cacheEnabled={false}
|
||||
incognito
|
||||
scrollEnabled={false}
|
||||
overScrollMode="never"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function configurationFromProps(props: SourceEditorProps): SourceEditorConfiguration {
|
||||
return { filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme };
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
webview: { flex: 1 },
|
||||
}));
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SourceEditorController } from "./controller";
|
||||
import type { SourceEditorConfiguration, SourceEditorProps } from "./contract";
|
||||
import { CodeMirrorRuntime } from "./codemirror/runtime";
|
||||
|
||||
let nextEditorKey = 1;
|
||||
|
||||
export function SourceEditor(props: SourceEditorProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const runtimeRef = useRef<CodeMirrorRuntime | null>(null);
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
const initial = useRef(props);
|
||||
const [editorKey] = useState(() => `web-source-editor-${nextEditorKey++}`);
|
||||
const controllerRef = useRef<SourceEditorController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new SourceEditorController({
|
||||
editorKey,
|
||||
document: initial.current.document,
|
||||
callbacks: {
|
||||
onChange: (document) => propsRef.current.onChange(document),
|
||||
onSave: () => propsRef.current.onSave(),
|
||||
onCursorChange: (position) => propsRef.current.onCursorChange(position),
|
||||
onVimModeChange: (mode) => propsRef.current.onVimModeChange(mode),
|
||||
},
|
||||
});
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
const configuration = useMemo<SourceEditorConfiguration>(
|
||||
() => ({ filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme }),
|
||||
[props.filename, props.theme, props.vimEnabled],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const runtime = new CodeMirrorRuntime();
|
||||
runtimeRef.current = runtime;
|
||||
runtime.mount({
|
||||
host,
|
||||
document: controller.getRuntimeDocument(),
|
||||
configuration: configurationFromProps(initial.current),
|
||||
callbacks: {
|
||||
onChange: (changes) => controller.receive({ type: "change", editorKey, changes }),
|
||||
onSave: () => controller.receive({ type: "save", editorKey }),
|
||||
onCursorChange: (position) =>
|
||||
controller.receive({ type: "cursor", editorKey, ...position }),
|
||||
onVimModeChange: (mode) => controller.receive({ type: "vimMode", editorKey, mode }),
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
runtime.destroy();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, [controller, editorKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const replacement = controller.replaceDocument(props.document);
|
||||
if (replacement !== null) runtimeRef.current?.replaceDocument(replacement);
|
||||
}, [controller, props.document]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.configure(configuration);
|
||||
}, [configuration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.selectionTarget) runtimeRef.current?.reveal(props.selectionTarget);
|
||||
}, [props.selectionTarget]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${props.filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function configurationFromProps(props: SourceEditorProps): SourceEditorConfiguration {
|
||||
return { filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme };
|
||||
}
|
||||
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"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",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/server": "0.7.0",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/server": "0.7.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -9,7 +9,6 @@ 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() {} };
|
||||
@@ -26,7 +25,6 @@ describe("Hub commands", () => {
|
||||
"connect",
|
||||
"status",
|
||||
"disconnect",
|
||||
"permissions",
|
||||
"projects",
|
||||
"export",
|
||||
"deploy",
|
||||
@@ -176,87 +174,12 @@ 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",
|
||||
permissions: [],
|
||||
},
|
||||
{ origin: "https://hub.test", token: "one-time-enrollment-token-with-enough-length" },
|
||||
]);
|
||||
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[] = [];
|
||||
@@ -600,39 +523,22 @@ class FakeDaemonConnection implements HubDaemonConnection {
|
||||
}
|
||||
|
||||
class FakeDaemon implements HubDaemonClient {
|
||||
readonly connections: Array<{
|
||||
origin: string;
|
||||
token: string;
|
||||
permissions: readonly string[];
|
||||
}> = [];
|
||||
readonly connections: Array<{ origin: string; token: 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, 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 connectHub(origin: string, token: string) {
|
||||
this.connections.push({ origin, token });
|
||||
return { status: hubStatus("connected", origin) };
|
||||
}
|
||||
|
||||
async getHubStatus() {
|
||||
return {
|
||||
status: hubStatus("connected", this.origin, this.returnedPermissions ?? ["hub.execute"]),
|
||||
};
|
||||
return { status: hubStatus("connected", this.origin) };
|
||||
}
|
||||
|
||||
async getProvidersSnapshot() {
|
||||
@@ -653,16 +559,12 @@ class FakeDaemon implements HubDaemonClient {
|
||||
async close() {}
|
||||
}
|
||||
|
||||
function hubStatus(
|
||||
state: string,
|
||||
origin: string | null,
|
||||
permissions: readonly string[] = ["hub.execute"],
|
||||
): HubStatus {
|
||||
function hubStatus(state: string, origin: string | null): HubStatus {
|
||||
return {
|
||||
state,
|
||||
daemonId: state === "connected" ? "daemon-1" : null,
|
||||
hubOrigin: origin,
|
||||
permissions: state === "connected" ? [...permissions] : [],
|
||||
scopes: state === "connected" ? ["hub.execution.*"] : [],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
@@ -14,8 +14,6 @@ interface HubConnectOptions {
|
||||
apiKey?: string;
|
||||
host?: string;
|
||||
json?: boolean;
|
||||
permission?: readonly string[];
|
||||
permissions?: readonly string[];
|
||||
}
|
||||
|
||||
interface HubConnectDependencies {
|
||||
@@ -40,18 +38,8 @@ 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, 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.",
|
||||
);
|
||||
}
|
||||
const response = await daemon.connectHub(origin, token);
|
||||
return hubStatusResult(response.status);
|
||||
});
|
||||
}
|
||||
@@ -63,8 +51,7 @@ 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("--permission <permission...>", "Grant daemon permission during connection"),
|
||||
.option("--api-key <secret>", "Organization API key"),
|
||||
),
|
||||
).action(
|
||||
withOutput(async (...args) => {
|
||||
@@ -74,7 +61,3 @@ 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));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface HubStatus {
|
||||
state: string;
|
||||
daemonId: string | null;
|
||||
hubOrigin: string | null;
|
||||
permissions: string[];
|
||||
scopes: string[];
|
||||
connectedAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
@@ -15,15 +15,7 @@ export interface HubProvidersSnapshotOptions {
|
||||
}
|
||||
|
||||
export interface HubDaemonClient {
|
||||
connectHub(
|
||||
url: string,
|
||||
token: string,
|
||||
permissions?: readonly string[],
|
||||
): Promise<{ status: HubStatus }>;
|
||||
updateHubPermissions(input: {
|
||||
grant?: readonly string[];
|
||||
revoke?: readonly string[];
|
||||
}): Promise<{ status: HubStatus }>;
|
||||
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
|
||||
getHubStatus(): Promise<{ status: HubStatus }>;
|
||||
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
|
||||
getProvidersSnapshot(
|
||||
|
||||
@@ -20,7 +20,6 @@ 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>>;
|
||||
@@ -81,10 +80,6 @@ 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, false], [], []);
|
||||
const prompts = new PromptAnswers([true], [], []);
|
||||
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
||||
|
||||
@@ -42,13 +42,9 @@ describe("Hub guided setup continuation", () => {
|
||||
},
|
||||
);
|
||||
|
||||
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.confirmations, ["Connect this daemon to this Hub?"]);
|
||||
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" }]);
|
||||
@@ -74,26 +70,6 @@ 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();
|
||||
@@ -357,7 +333,6 @@ class SetupDaemon implements HubDaemonClient {
|
||||
connections = 0;
|
||||
readonly snapshotCwds: Array<string | undefined> = [];
|
||||
private origin: string | null = null;
|
||||
private currentPermissions: string[] = [];
|
||||
|
||||
constructor(
|
||||
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
|
||||
@@ -380,26 +355,14 @@ class SetupDaemon implements HubDaemonClient {
|
||||
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
|
||||
) {}
|
||||
|
||||
async connectHub(origin: string, _token: string, permissions: readonly string[] = []) {
|
||||
async connectHub(origin: string) {
|
||||
this.connections += 1;
|
||||
this.origin = 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) };
|
||||
return { status: status(origin) };
|
||||
}
|
||||
|
||||
async getHubStatus() {
|
||||
return {
|
||||
status:
|
||||
this.origin === null ? disconnectedStatus() : status(this.origin, this.currentPermissions),
|
||||
};
|
||||
return { status: this.origin === null ? disconnectedStatus() : status(this.origin) };
|
||||
}
|
||||
|
||||
async getProvidersSnapshot(options?: { cwd?: string }) {
|
||||
@@ -418,12 +381,12 @@ class SetupDaemon implements HubDaemonClient {
|
||||
async close() {}
|
||||
}
|
||||
|
||||
function status(origin: string, permissions: readonly string[] = []): HubStatus {
|
||||
function status(origin: string): HubStatus {
|
||||
return {
|
||||
state: "connected",
|
||||
daemonId: "daemon-1",
|
||||
hubOrigin: origin,
|
||||
permissions: [...permissions],
|
||||
scopes: [],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
@@ -434,7 +397,7 @@ function disconnectedStatus(): HubStatus {
|
||||
state: "not_connected",
|
||||
daemonId: null,
|
||||
hubOrigin: null,
|
||||
permissions: [],
|
||||
scopes: [],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("Hub init planning", () => {
|
||||
state: "connected",
|
||||
daemonId: "daemon-1",
|
||||
hubOrigin: "https://hub.test",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
@@ -191,43 +191,8 @@ export async function continueHubGuidedSetup(
|
||||
origin: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<void> {
|
||||
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",
|
||||
);
|
||||
}
|
||||
if (await requiredConfirm(environment, "Connect this daemon to this Hub?", true)) {
|
||||
await ensureDaemonConnection(origin, environment, true);
|
||||
} else {
|
||||
reportMessage(
|
||||
environment,
|
||||
@@ -287,19 +252,12 @@ 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") {
|
||||
@@ -313,25 +271,20 @@ async function ensureDaemonConnection(
|
||||
}
|
||||
if (
|
||||
!confirmed &&
|
||||
!(await requiredConfirm(
|
||||
environment,
|
||||
`Connect this daemon to ${origin} and allow Hub workflows to create and control workspaces and agents?`,
|
||||
true,
|
||||
))
|
||||
!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))
|
||||
) {
|
||||
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
|
||||
}
|
||||
return connectDaemon(origin, environment, permissions);
|
||||
return connectDaemon(origin, environment);
|
||||
}
|
||||
|
||||
async function connectDaemon(
|
||||
origin: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
permissions: readonly string[] = ["hub.execute"],
|
||||
): Promise<string> {
|
||||
await runHubConnect(
|
||||
origin,
|
||||
{ permissions },
|
||||
{},
|
||||
{
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
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;
|
||||
permissions: string;
|
||||
scopes: 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: "PERMISSIONS", field: "permissions" },
|
||||
{ header: "SCOPES", field: "scopes" },
|
||||
{ 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,
|
||||
permissions: status.permissions.join(", "),
|
||||
scopes: status.scopes.join(", "),
|
||||
connectedAt: status.connectedAt,
|
||||
error: status.lastError,
|
||||
warning,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/relay": "0.7.0-beta.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -309,7 +309,7 @@ export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteRe
|
||||
export interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp";
|
||||
appVersion?: string;
|
||||
runtimeGeneration?: number | null;
|
||||
password?: string;
|
||||
@@ -4680,36 +4680,15 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async connectHub(
|
||||
hubUrl: string,
|
||||
token: string,
|
||||
permissions: readonly string[] = [],
|
||||
requestId?: string,
|
||||
) {
|
||||
async connectHub(hubUrl: string, token: string, requestId?: string) {
|
||||
this.requireHubRelationshipSupport();
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "hub.management.daemon.connect.request", hubUrl, token, permissions },
|
||||
message: { type: "hub.management.daemon.connect.request", hubUrl, token },
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -44,8 +44,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@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",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -335,16 +335,9 @@ 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);
|
||||
});
|
||||
@@ -358,7 +351,7 @@ describe("Hub session protocol", () => {
|
||||
state: "connected",
|
||||
daemonId: "daemon-1",
|
||||
hubOrigin: "https://hub.example",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
connectedAt: "2026-07-13T00:00:00.000Z",
|
||||
lastError: null,
|
||||
},
|
||||
@@ -372,7 +365,7 @@ describe("Hub session protocol", () => {
|
||||
state: "not_connected",
|
||||
daemonId: null,
|
||||
hubOrigin: null,
|
||||
permissions: [],
|
||||
scopes: [],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
@@ -386,27 +379,13 @@ describe("Hub session protocol", () => {
|
||||
state: "disconnecting",
|
||||
daemonId: "daemon-1",
|
||||
hubOrigin: "https://hub.example",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -93,20 +93,6 @@ 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),
|
||||
@@ -1407,7 +1393,6 @@ 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"),
|
||||
@@ -1418,12 +1403,6 @@ 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"),
|
||||
@@ -3032,7 +3011,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
HubManagementDaemonConnectRequestSchema,
|
||||
HubManagementDaemonGetStatusRequestSchema,
|
||||
HubManagementDaemonDisconnectRequestSchema,
|
||||
HubManagementDaemonPermissionsUpdateRequestSchema,
|
||||
DiagnosticsRequestSchema,
|
||||
PluginCatalogGetRequestSchema,
|
||||
PluginListRequestSchema,
|
||||
@@ -3347,8 +3325,6 @@ 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(),
|
||||
@@ -4623,7 +4599,7 @@ export const HubRelationshipStatusSchema = z.object({
|
||||
]),
|
||||
daemonId: z.string().nullable(),
|
||||
hubOrigin: z.string().nullable(),
|
||||
permissions: z.array(DaemonPermissionSchema),
|
||||
scopes: z.array(z.string()),
|
||||
connectedAt: z.string().nullable(),
|
||||
lastError: z.string().nullable(),
|
||||
});
|
||||
@@ -4643,10 +4619,6 @@ 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"),
|
||||
@@ -6390,7 +6362,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
HubManagementDaemonConnectResponseSchema,
|
||||
HubManagementDaemonGetStatusResponseSchema,
|
||||
HubManagementDaemonDisconnectResponseSchema,
|
||||
HubManagementDaemonPermissionsUpdateResponseSchema,
|
||||
DiagnosticsResponseSchema,
|
||||
GetDaemonConfigResponseMessageSchema,
|
||||
SetDaemonConfigResponseMessageSchema,
|
||||
@@ -6965,7 +6936,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", "hub"]),
|
||||
clientType: z.enum(["mobile", "browser", "cli", "mcp"]),
|
||||
protocolVersion: z.number().int(),
|
||||
appVersion: z.string().optional(),
|
||||
capabilities: z
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"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",
|
||||
"@getpaseo/highlight": "0.7.0",
|
||||
"@getpaseo/plugin": "0.7.0",
|
||||
"@getpaseo/protocol": "0.7.0",
|
||||
"@getpaseo/relay": "0.7.0",
|
||||
"@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",
|
||||
"@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,88 +5543,6 @@ 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,7 +2165,6 @@ 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,
|
||||
@@ -2233,7 +2232,8 @@ export class AgentManager {
|
||||
agent.pendingReplacement = false;
|
||||
}
|
||||
const turnStartedAt = new Date();
|
||||
pendingRun.start = { status: "started", turnId };
|
||||
pendingRun.started = true;
|
||||
pendingRun.turnId = turnId;
|
||||
agent.activeForegroundTurnId = turnId;
|
||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||
agent.lifecycle = "running";
|
||||
@@ -2575,10 +2575,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
const pendingRun = this.runs.getPendingRun(agentId);
|
||||
if (
|
||||
(snapshot.lifecycle === "running" || pendingRun?.start.status === "started") &&
|
||||
!snapshot.pendingReplacement
|
||||
) {
|
||||
if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2643,19 +2640,14 @@ export class AgentManager {
|
||||
|
||||
const currentPendingRun = this.runs.getPendingRun(agentId);
|
||||
if (
|
||||
(current.lifecycle === "running" || currentPendingRun?.start.status === "started") &&
|
||||
(current.lifecycle === "running" || currentPendingRun?.started) &&
|
||||
!current.pendingReplacement
|
||||
) {
|
||||
finishOk();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentPendingRun?.start.status === "failed") {
|
||||
finishErr(new Error(currentPendingRun.start.error));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.lifecycle === "error" && !currentPendingRun) {
|
||||
if (current.lifecycle === "error" && !currentPendingRun?.started) {
|
||||
finishErr(new Error(current.lastError ?? `Agent ${agentId} failed to start`));
|
||||
return true;
|
||||
}
|
||||
@@ -2742,17 +2734,16 @@ export class AgentManager {
|
||||
return { status: settlement === "completed" ? "settled" : "refused" };
|
||||
}
|
||||
|
||||
const runTurnId = this.runs.getTurnId(agentId);
|
||||
if (settlement === "timed_out" && runTurnId) {
|
||||
if (settlement === "timed_out" && run.turnId) {
|
||||
this.logger.warn(
|
||||
{ agentId, turnId: runTurnId, kind: run.kind },
|
||||
{ agentId, turnId: run.turnId, 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: runTurnId,
|
||||
turnId: run.turnId,
|
||||
});
|
||||
await run.settledPromise;
|
||||
} else if (settlement === "timed_out" && run.kind === "foreground") {
|
||||
@@ -3032,7 +3023,7 @@ export class AgentManager {
|
||||
let hasStarted =
|
||||
isAgentBusy(initialStatus) ||
|
||||
Boolean(snapshot.activeForegroundTurnId) ||
|
||||
pendingForegroundRun?.start.status === "started";
|
||||
Boolean(pendingForegroundRun?.started);
|
||||
let terminalStatusOverride: AgentLifecycleStatus | null = null;
|
||||
let finished = false;
|
||||
|
||||
@@ -3438,7 +3429,7 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
const pendingRun = this.runs.getPendingRun(agentId);
|
||||
if (pendingRun?.start.status === "pending") {
|
||||
if (pendingRun && !pendingRun.started) {
|
||||
pendingRun.stagedEvents.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,8 @@ export interface PendingForegroundRun {
|
||||
token: string;
|
||||
kind: "foreground";
|
||||
stagedEvents: AgentStreamEvent[];
|
||||
start:
|
||||
| { status: "pending" }
|
||||
| { status: "started"; turnId: string }
|
||||
| { status: "failed"; error: string };
|
||||
turnId: string | null;
|
||||
started: boolean;
|
||||
settled: boolean;
|
||||
settledPromise: Promise<void>;
|
||||
resolveSettled: () => void;
|
||||
@@ -66,13 +64,6 @@ 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) {
|
||||
@@ -94,10 +85,7 @@ export class AgentRunState {
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
run.kind === "foreground" &&
|
||||
(run.start.status !== "started" || run.start.turnId !== turnId)
|
||||
) {
|
||||
if (run.kind === "foreground" && (run.turnId === null || run.turnId !== turnId)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -280,7 +268,8 @@ function createPendingForegroundRun(): PendingForegroundRun {
|
||||
return {
|
||||
...createTrackedRunState(),
|
||||
kind: "foreground",
|
||||
start: { status: "pending" },
|
||||
turnId: null,
|
||||
started: false,
|
||||
stagedEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,14 +54,6 @@ 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;
|
||||
@@ -71,7 +63,6 @@ interface InMemoryChildProcess extends ChildProcessWithoutNullStreams {
|
||||
interface StartProcessOptions {
|
||||
child?: ChildProcessWithoutNullStreams;
|
||||
defaultRequestTimeoutMs?: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
function createInMemoryChildProcess(): InMemoryChildProcess {
|
||||
@@ -94,7 +85,7 @@ function startProcess(options: StartProcessOptions = {}): JsonlRpcProcess {
|
||||
return new JsonlRpcProcess({
|
||||
launch: {
|
||||
command: process.execPath,
|
||||
args: ["-e", options.source ?? CHILD_SOURCE, "--", "resolved-arg"],
|
||||
args: ["-e", CHILD_SOURCE, "--", "resolved-arg"],
|
||||
cwd: process.cwd(),
|
||||
env: { JSONL_RPC_TEST_VALUE: "resolved-env" },
|
||||
},
|
||||
@@ -231,70 +222,6 @@ 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,9 +104,6 @@ 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)));
|
||||
});
|
||||
@@ -176,18 +173,10 @@ export class JsonlRpcProcess {
|
||||
}
|
||||
|
||||
send(message: Record<string, unknown>): void {
|
||||
if (this.disposed) {
|
||||
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
|
||||
@@ -254,15 +243,6 @@ 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 },
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2811,7 +2811,7 @@ function appendOpenCodeTextPart(
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (messageRole === "user") {
|
||||
if (!part.text || state.emittedUserMessageIds?.has(part.messageID)) {
|
||||
if (!part.time?.end || !part.text || state.emittedUserMessageIds?.has(part.messageID)) {
|
||||
return;
|
||||
}
|
||||
state.emittedUserMessageIds?.add(part.messageID);
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
OWNER_PERMISSIONS,
|
||||
SessionAuthorization,
|
||||
permissionsForLegacyHubScopes,
|
||||
parseDaemonPermissions,
|
||||
} from "./index.js";
|
||||
|
||||
function inboundOperationTypes(): SessionInboundMessage["type"][] {
|
||||
@@ -72,9 +71,4 @@ 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,23 +1,22 @@
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../messages.js";
|
||||
import { DAEMON_PERMISSIONS, type DaemonPermission } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
requiredPermissionForInbound,
|
||||
requiredPermissionForOutbound,
|
||||
} from "./operation-permissions.js";
|
||||
|
||||
export { DAEMON_PERMISSIONS, type DaemonPermission };
|
||||
export const DAEMON_PERMISSIONS = [
|
||||
"daemon.read",
|
||||
"daemon.manage",
|
||||
"tunnel.manage",
|
||||
"access.manage",
|
||||
"workspace.read",
|
||||
"workspace.write",
|
||||
"workspace.manage",
|
||||
"automation.manage",
|
||||
"hub.execute",
|
||||
] as const;
|
||||
|
||||
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 type DaemonPermission = (typeof DAEMON_PERMISSIONS)[number];
|
||||
|
||||
export const OWNER_PERMISSIONS: readonly DaemonPermission[] = DAEMON_PERMISSIONS;
|
||||
|
||||
@@ -40,14 +39,6 @@ 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,7 +96,6 @@ 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",
|
||||
@@ -306,7 +305,6 @@ 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,13 +536,10 @@ describe("paseo daemon bootstrap", () => {
|
||||
await enrollmentReleased;
|
||||
return {
|
||||
daemonId: input.daemonId,
|
||||
permissions: input.permissions,
|
||||
scopes: input.scopes,
|
||||
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 };
|
||||
|
||||
@@ -671,7 +671,7 @@ export async function createPaseoDaemon(
|
||||
serviceProxy,
|
||||
onChange: createScriptStatusEmitter({
|
||||
sessions: () =>
|
||||
wsServer?.listSessions().map((session) => ({
|
||||
wsServer?.listTrustedSessions().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?.listSessions() ?? [],
|
||||
sessions: wsServer?.listTrustedSessions() ?? [],
|
||||
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?.listSessions() ?? []) {
|
||||
for (const session of wsServer?.listTrustedSessions() ?? []) {
|
||||
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
|
||||
}
|
||||
};
|
||||
const clearWorkspaceArchivingExternal = (workspaceIds: Iterable<string>) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
for (const session of wsServer?.listSessions() ?? []) {
|
||||
for (const session of wsServer?.listTrustedSessions() ?? []) {
|
||||
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
|
||||
}
|
||||
};
|
||||
const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable<string>) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
await Promise.all(
|
||||
(wsServer?.listSessions() ?? []).map((session) =>
|
||||
(wsServer?.listTrustedSessions() ?? []).map((session) =>
|
||||
session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList),
|
||||
),
|
||||
);
|
||||
@@ -1108,7 +1108,7 @@ export async function createPaseoDaemon(
|
||||
warmWorkspaceGitData: async (workspace) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listSessions()
|
||||
?.listTrustedSessions()
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
||||
);
|
||||
},
|
||||
@@ -1206,27 +1206,7 @@ export async function createPaseoDaemon(
|
||||
createDaemonId: dependencies.createHubDaemonId,
|
||||
attachSocket: async (socket, options) => {
|
||||
if (!wsServer) throw new Error("WebSocket server is not running");
|
||||
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);
|
||||
await wsServer.attachHubSocket(socket, options);
|
||||
},
|
||||
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 session applies its scoped authorization to ordinary protocol messages", async () => {
|
||||
test("Hub denies trusted steering and browser dispatch", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const localAgentId = await hub.createUnrelatedLocalAgent();
|
||||
|
||||
@@ -55,34 +55,22 @@ test("Hub session applies its scoped authorization to ordinary protocol messages
|
||||
code: "access_denied",
|
||||
});
|
||||
expect(hub.observedAgentIds()).not.toContain(localAgentId);
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
|
||||
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
test("Hub completes the standard hello before rejecting a second hello", async () => {
|
||||
test("Hub sockets reject trusted hello and capabilities", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
|
||||
expect(hub.probeTrustedHello()).toBe(4002);
|
||||
});
|
||||
|
||||
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(launched.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
|
||||
expect(launched.serverInfoPermissions()).toEqual([["hub.execute"]]);
|
||||
});
|
||||
|
||||
test("Hub binary frames enter the standard active-session path", async () => {
|
||||
test("Hub sockets reject trusted binary frames", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
expect(hub.probeBinaryFrame()).toBeNull();
|
||||
expect(hub.probeBinaryFrame()).toBe(4002);
|
||||
});
|
||||
|
||||
test("Hub receives standard server info but not broadcasts outside its scope", async () => {
|
||||
test("Hub does not receive trusted broadcasts", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
const trustedBroadcasts = await hub.trustedBroadcastCount();
|
||||
@@ -90,10 +78,10 @@ test("Hub receives standard server info but not broadcasts outside its scope", a
|
||||
|
||||
expect(trustedBroadcasts).toBe(0);
|
||||
expect(trustedStatus).toMatchObject({ pid: process.pid, relay: { enabled: false } });
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
test("Hub reconnects through the standard resumable session bootstrap", async () => {
|
||||
test("Hub reconnects without retaining trusted session state", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const created = await hub.createOwnedConcurrently();
|
||||
|
||||
@@ -103,8 +91,7 @@ test("Hub reconnects through the standard resumable session bootstrap", async ()
|
||||
executionId: "execution-1",
|
||||
agentId: created.first.agentId,
|
||||
});
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info", "server_info"]);
|
||||
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"], ["hub.execute"]]);
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
test("Hub interrupts an owned running execution idempotently", async () => {
|
||||
|
||||
@@ -98,68 +98,27 @@ describe("Hub relationship", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("new relationships can connect without granting Hub execution", async () => {
|
||||
test("Hub enrollment cannot widen the daemon's locally granted scopes", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.returnEnrollmentPermissions([]);
|
||||
relationship.returnEnrollmentScopes(["hub.execution.*", "*"]);
|
||||
|
||||
await relationship.beginConnect("registered-token", "https://hub.example", false).result;
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
const responses = relationship.sendHubRequestOnLatest({
|
||||
type: "hub.execution.agent.create.request",
|
||||
requestId: "registered-execution",
|
||||
executionId: "execution-1",
|
||||
provider: "codex",
|
||||
cwd: "/workspace",
|
||||
prompt: "Do not run",
|
||||
type: "daemon.get_status.request",
|
||||
requestId: "scope-escalation",
|
||||
});
|
||||
|
||||
expect(relationship.relationshipFile()?.relationship.permissions).toEqual([]);
|
||||
expect(relationship.relationshipFile()?.relationship.scopes).toEqual(["hub.execution.*"]);
|
||||
expect(responses).toContainEqual({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "registered-execution",
|
||||
requestType: "hub.execution.agent.create.request",
|
||||
error: "Session is not authorized for hub.execution.agent.create.request",
|
||||
requestId: "scope-escalation",
|
||||
requestType: "daemon.get_status.request",
|
||||
error: "Session is not authorized for daemon.get_status.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 () => {
|
||||
@@ -273,33 +232,6 @@ 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"],
|
||||
@@ -332,12 +264,12 @@ describe("Hub relationship", () => {
|
||||
expect(relationship.enrollmentAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a persisted Hub relationship cannot widen its local permissions", async () => {
|
||||
test("a persisted Hub relationship cannot widen its local execution scope", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
const persisted = relationship.relationshipFile();
|
||||
expect(persisted).not.toBeNull();
|
||||
persisted!.relationship.permissions = ["*"];
|
||||
persisted!.relationship.scopes = ["*"];
|
||||
await relationship.corruptRelationshipFile(JSON.stringify(persisted));
|
||||
const socketAttemptsBeforeRestart = relationship.socketAttempts();
|
||||
|
||||
@@ -812,14 +744,14 @@ describe("Hub relationship", () => {
|
||||
state: "revoked",
|
||||
daemonId: enrollment.daemonId,
|
||||
hub: "https://hub.test",
|
||||
permissions: "hub.execute",
|
||||
scopes: "hub.execution.*",
|
||||
error: reason,
|
||||
});
|
||||
expect(persisted?.state).toBe("revoked");
|
||||
expect(persisted?.relationship).toMatchObject({
|
||||
daemonId: enrollment.daemonId,
|
||||
hubOrigin: "https://hub.test",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
});
|
||||
expect(persisted?.reason).toBe(reason);
|
||||
expect(persisted).not.toHaveProperty("credential");
|
||||
|
||||
@@ -5,12 +5,6 @@ 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,
|
||||
@@ -21,6 +15,8 @@ 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()
|
||||
@@ -35,22 +31,12 @@ 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(),
|
||||
permissions: DaemonPermissionsSchema,
|
||||
scopes: z.tuple([z.literal(HUB_EXECUTION_SCOPE)]),
|
||||
});
|
||||
const SanitizedRelationshipSchema = RelationshipSchema.omit({ idempotencyKey: true });
|
||||
const CredentialSchema = z.object({ secret: z.string().min(1) });
|
||||
@@ -63,7 +49,7 @@ const TransportSchema = z.object({
|
||||
.refine((value) => new URL(value).hash === ""),
|
||||
});
|
||||
const PendingSchema = z.object({
|
||||
version: z.literal(2),
|
||||
version: z.literal(1),
|
||||
state: z.literal("pending"),
|
||||
relationship: RelationshipSchema,
|
||||
credential: CredentialSchema,
|
||||
@@ -71,27 +57,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(2),
|
||||
version: z.literal(1),
|
||||
state: z.literal("active"),
|
||||
relationship: RelationshipSchema,
|
||||
credential: CredentialSchema,
|
||||
transport: TransportSchema,
|
||||
});
|
||||
const DisconnectingSchema = z.object({
|
||||
version: z.literal(2),
|
||||
version: z.literal(1),
|
||||
state: z.literal("disconnecting"),
|
||||
relationship: RelationshipSchema,
|
||||
credential: CredentialSchema,
|
||||
transport: TransportSchema.optional(),
|
||||
});
|
||||
const RevokedSchema = z.object({
|
||||
version: z.literal(2),
|
||||
version: z.literal(1),
|
||||
state: z.literal("revoked"),
|
||||
relationship: SanitizedRelationshipSchema,
|
||||
transport: TransportSchema.optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
const CurrentRecordSchema = z
|
||||
const RecordSchema = z
|
||||
.discriminatedUnion("state", [PendingSchema, ActiveSchema, DisconnectingSchema, RevokedSchema])
|
||||
.superRefine((record, context) => {
|
||||
if (!("transport" in record) || !record.transport) return;
|
||||
@@ -105,7 +91,6 @@ const CurrentRecordSchema = 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>;
|
||||
@@ -124,21 +109,13 @@ export interface HubRelationshipStatus {
|
||||
state: HubConnectionState;
|
||||
daemonId: string | null;
|
||||
hubOrigin: string | null;
|
||||
permissions: DaemonPermission[];
|
||||
scopes: string[];
|
||||
connectedAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface HubRelationshipManagement {
|
||||
connect(input: {
|
||||
hubUrl: string;
|
||||
token: string;
|
||||
permissions: readonly string[];
|
||||
}): Promise<HubRelationshipStatus>;
|
||||
updatePermissions(input: {
|
||||
grant: readonly string[];
|
||||
revoke: readonly string[];
|
||||
}): Promise<HubRelationshipStatus>;
|
||||
connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus>;
|
||||
status(): HubRelationshipStatus;
|
||||
disconnect(input: {
|
||||
force: boolean;
|
||||
@@ -170,18 +147,8 @@ export interface HubRelationshipControllerOptions {
|
||||
createDaemonId?: () => string;
|
||||
attachSocket: (
|
||||
socket: WebSocketLike,
|
||||
options: {
|
||||
daemonId: string;
|
||||
principalId: string;
|
||||
permissions: readonly DaemonPermission[];
|
||||
agents: HubExecutionAgents;
|
||||
sessionProtocol: "legacy" | "session-v1";
|
||||
},
|
||||
options: { daemonId: string; scopes: readonly string[]; agents: HubExecutionAgents },
|
||||
) => Promise<void>;
|
||||
updateAttachedPermissions: (
|
||||
principalId: string,
|
||||
permissions: readonly DaemonPermission[],
|
||||
) => void;
|
||||
createExecutionAgents: (daemonId: string) => HubExecutionAgents;
|
||||
}
|
||||
|
||||
@@ -274,18 +241,13 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
state: this.state,
|
||||
daemonId: this.record?.relationship.daemonId ?? null,
|
||||
hubOrigin: this.record?.relationship.hubOrigin ?? null,
|
||||
permissions: this.record?.relationship.permissions.slice() ?? [],
|
||||
scopes: this.record?.relationship.scopes.slice() ?? [],
|
||||
connectedAt: this.connectedAt,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
async connect(input: {
|
||||
hubUrl: string;
|
||||
token: string;
|
||||
permissions: readonly string[];
|
||||
}): Promise<HubRelationshipStatus> {
|
||||
const permissions = parseDaemonPermissions(input.permissions);
|
||||
async connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus> {
|
||||
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");
|
||||
@@ -302,14 +264,14 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
throw new Error("This daemon already has a Hub relationship");
|
||||
}
|
||||
const pending: PendingRecord = {
|
||||
version: 2,
|
||||
version: 1,
|
||||
state: "pending",
|
||||
relationship: {
|
||||
daemonId: this.options.createDaemonId?.() ?? randomUUID(),
|
||||
idempotencyKey: randomUUID(),
|
||||
hubOrigin: normalizeHubUrl(input.hubUrl),
|
||||
createdAt: this.clock.now().toISOString(),
|
||||
permissions,
|
||||
scopes: [...SCOPES],
|
||||
},
|
||||
credential: { secret: randomBytes(32).toString("base64url") },
|
||||
enrollment: { token: input.token },
|
||||
@@ -323,37 +285,6 @@ 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 }> {
|
||||
@@ -368,7 +299,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
return { status: this.status() };
|
||||
}
|
||||
const disconnecting: DisconnectingRecord = {
|
||||
version: 2,
|
||||
version: 1,
|
||||
state: "disconnecting",
|
||||
relationship: this.record.relationship,
|
||||
credential: this.record.credential,
|
||||
@@ -414,7 +345,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
serverId: pending.identity.serverId,
|
||||
daemonPublicKey: pending.identity.daemonPublicKey,
|
||||
credentialVerifier: verifier,
|
||||
permissions: pending.relationship.permissions,
|
||||
scopes: pending.relationship.scopes,
|
||||
});
|
||||
const settled = request.then(
|
||||
() => undefined,
|
||||
@@ -426,12 +357,12 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
if (
|
||||
enrollment.daemonId !== pending.relationship.daemonId ||
|
||||
!samePermissions(enrollment.permissions, pending.relationship.permissions)
|
||||
!enrollment.scopes.includes(HUB_EXECUTION_SCOPE)
|
||||
) {
|
||||
throw new Error("Hub enrollment response did not match the pending relationship");
|
||||
}
|
||||
const active: ActiveRecord = {
|
||||
version: 2,
|
||||
version: 1,
|
||||
state: "active",
|
||||
relationship: pending.relationship,
|
||||
credential: pending.credential,
|
||||
@@ -459,8 +390,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
const generation = ++this.generation;
|
||||
this.state = reconnecting ? "reconnecting" : "connecting";
|
||||
const events: HubSocketEvents = {
|
||||
connected: (socket, sessionProtocol) =>
|
||||
this.socketConnected(generation, record, socket, sessionProtocol),
|
||||
connected: (socket) => this.socketConnected(generation, record, socket),
|
||||
rejected: (statusCode) => this.socketRejected(generation, statusCode),
|
||||
closed: (code) => this.socketClosed(generation, record, code),
|
||||
failed: (error) => this.socketFailed(generation, record, error),
|
||||
@@ -475,12 +405,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
);
|
||||
}
|
||||
|
||||
private socketConnected(
|
||||
generation: number,
|
||||
record: ActiveRecord,
|
||||
socket: WebSocketLike,
|
||||
sessionProtocol: "legacy" | "session-v1",
|
||||
): void {
|
||||
private socketConnected(generation: number, record: ActiveRecord, socket: WebSocketLike): void {
|
||||
if (generation !== this.generation) {
|
||||
socket.close();
|
||||
return;
|
||||
@@ -491,10 +416,8 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
this.lastError = null;
|
||||
void this.options.attachSocket(socket, {
|
||||
daemonId: record.relationship.daemonId,
|
||||
principalId: hubPrincipalId(record),
|
||||
permissions: record.relationship.permissions,
|
||||
scopes: record.relationship.scopes,
|
||||
agents: this.executionAgentsFor(record.relationship.daemonId),
|
||||
sessionProtocol,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -564,13 +487,13 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
this.cancelLifecycle();
|
||||
if (!this.record) return;
|
||||
const revoked: RevokedRecord = {
|
||||
version: 2,
|
||||
version: 1,
|
||||
state: "revoked",
|
||||
relationship: {
|
||||
daemonId: this.record.relationship.daemonId,
|
||||
hubOrigin: this.record.relationship.hubOrigin,
|
||||
createdAt: this.record.relationship.createdAt,
|
||||
permissions: this.record.relationship.permissions,
|
||||
scopes: this.record.relationship.scopes,
|
||||
},
|
||||
transport: "transport" in this.record ? this.record.transport : undefined,
|
||||
reason,
|
||||
@@ -613,9 +536,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
if (!existsSync(this.filePath)) return null;
|
||||
let record: HubRelationshipRecord;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
|
||||
record = RecordSchema.parse(raw);
|
||||
if (isLegacyRecord(raw)) this.persist(record);
|
||||
record = RecordSchema.parse(JSON.parse(readFileSync(this.filePath, "utf8")));
|
||||
} catch (error) {
|
||||
const quarantinePath = path.join(
|
||||
path.dirname(this.filePath),
|
||||
@@ -633,38 +554,3 @@ 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, WebSocketServer } from "ws";
|
||||
import { WebSocket } from "ws";
|
||||
import type { HubExecutionAgents } from "./daemon-executions.js";
|
||||
import {
|
||||
HubRelationshipController,
|
||||
@@ -51,87 +51,6 @@ 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();
|
||||
@@ -146,7 +65,7 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
@@ -172,7 +91,7 @@ test("enrollment rejects a transport URL that cannot open a WebSocket", async ()
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
}),
|
||||
).rejects.toThrow("Hub WebSocket URL must use ws or wss");
|
||||
expect(enrolledHostname).toBe("test-daemon.local");
|
||||
@@ -192,7 +111,7 @@ test("enrollment rejects a WebSocket URL with a fragment", async () => {
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
}),
|
||||
).rejects.toThrow("Hub WebSocket URL cannot include a fragment");
|
||||
});
|
||||
@@ -211,7 +130,7 @@ test("enrollment rejects a WebSocket outside the enrolled Hub authority", async
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
}),
|
||||
).rejects.toThrow("Hub WebSocket URL must match the Hub origin");
|
||||
});
|
||||
@@ -231,7 +150,7 @@ test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (oper
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
})
|
||||
: remote.revoke({
|
||||
daemonId: "daemon-1",
|
||||
@@ -392,7 +311,7 @@ async function startEnrollmentHub(
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
daemonId: enrollment.daemonId,
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
webSocketUrl,
|
||||
}),
|
||||
);
|
||||
@@ -483,7 +402,7 @@ class UpgradeRejectingHub {
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
daemonId: enrollment.daemonId,
|
||||
permissions: ["hub.execute"],
|
||||
scopes: ["hub.execution.*"],
|
||||
webSocketUrl: this.webSocketUrl,
|
||||
}),
|
||||
);
|
||||
@@ -625,14 +544,9 @@ async function connectController(
|
||||
clock,
|
||||
retryPolicy: clock,
|
||||
attachSocket: async () => undefined,
|
||||
updateAttachedPermissions: () => undefined,
|
||||
createExecutionAgents: () => unusedExecutionAgents,
|
||||
});
|
||||
await controller.connect({
|
||||
hubUrl: hub.origin,
|
||||
token: "enrollment-token",
|
||||
permissions: ["hub.execute"],
|
||||
});
|
||||
await controller.connect({ hubUrl: hub.origin, token: "enrollment-token" });
|
||||
return controller;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,22 +11,15 @@ export interface HubEnrollment {
|
||||
serverId: string;
|
||||
daemonPublicKey: string;
|
||||
credentialVerifier: string;
|
||||
permissions: string[];
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface HubEnrollmentResult {
|
||||
daemonId: string;
|
||||
permissions: string[];
|
||||
scopes: string[];
|
||||
webSocketUrl: string;
|
||||
}
|
||||
|
||||
export interface HubPermissionUpdate {
|
||||
daemonId: string;
|
||||
hubOrigin: string;
|
||||
credential: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface HubRevocation {
|
||||
daemonId: string;
|
||||
hubOrigin: string;
|
||||
@@ -40,7 +33,7 @@ export interface HubSocketCredentials {
|
||||
}
|
||||
|
||||
export interface HubSocketEvents {
|
||||
connected(socket: WebSocketLike, sessionProtocol: "legacy" | "session-v1"): void;
|
||||
connected(socket: WebSocketLike): void;
|
||||
rejected(statusCode: 401 | 403): void;
|
||||
closed(code: number): void;
|
||||
failed(error: Error): void;
|
||||
@@ -52,7 +45,6 @@ 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;
|
||||
}
|
||||
@@ -66,7 +58,7 @@ export class HubEnrollmentRejectedError extends Error {
|
||||
|
||||
const EnrollmentResultSchema = z.object({
|
||||
daemonId: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
scopes: z.array(z.string()),
|
||||
webSocketUrl: z
|
||||
.string()
|
||||
.url()
|
||||
@@ -109,7 +101,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
||||
serverId: input.serverId,
|
||||
daemonPublicKey: input.daemonPublicKey,
|
||||
credentialVerifier: input.credentialVerifier,
|
||||
permissions: input.permissions,
|
||||
scopes: input.scopes,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
@@ -125,25 +117,6 @@ 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(
|
||||
@@ -161,23 +134,16 @@ 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, sessionProtocol);
|
||||
if (!settled) events.connected(socket as WebSocketLike);
|
||||
});
|
||||
socket.once("unexpected-response", (_request, response) => {
|
||||
if (settled) {
|
||||
|
||||
@@ -85,7 +85,7 @@ interface PersistedRelationship {
|
||||
daemonId: string;
|
||||
idempotencyKey?: string;
|
||||
hubOrigin: string;
|
||||
permissions: string[];
|
||||
scopes: 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 enrollmentPermissions: string[] | null = null;
|
||||
private enrollmentScopes = ["hub.execution.*"];
|
||||
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;
|
||||
}
|
||||
|
||||
returnEnrollmentPermissions(permissions: string[]): void {
|
||||
this.enrollmentPermissions = permissions.slice();
|
||||
returnEnrollmentScopes(scopes: string[]): void {
|
||||
this.enrollmentScopes = scopes.slice();
|
||||
}
|
||||
|
||||
async enroll(input: HubEnrollment): Promise<HubEnrollmentResult> {
|
||||
this.enrollmentSnapshots.push(this.captureRelationship());
|
||||
this.enrollments.push({ ...input, permissions: input.permissions.slice() });
|
||||
this.enrollments.push({ ...input, scopes: input.scopes.slice() });
|
||||
this.relationships.add(input.idempotencyKey);
|
||||
this.enrollmentObserved.resolve();
|
||||
if (this.enrollmentRejection) {
|
||||
@@ -387,15 +387,6 @@ 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() };
|
||||
@@ -419,7 +410,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
|
||||
private enrollmentResult(input: HubEnrollment): HubEnrollmentResult {
|
||||
return {
|
||||
daemonId: input.daemonId,
|
||||
permissions: this.enrollmentPermissions?.slice() ?? input.permissions.slice(),
|
||||
scopes: this.enrollmentScopes.slice(),
|
||||
webSocketUrl: SOCKET_URL,
|
||||
};
|
||||
}
|
||||
@@ -514,16 +505,9 @@ export class HubRelationshipHarness {
|
||||
this.remote.holdEnrollment();
|
||||
}
|
||||
|
||||
beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN, execute = true): CliProcess {
|
||||
beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN): CliProcess {
|
||||
return {
|
||||
result: this.runCli([
|
||||
"hub",
|
||||
"connect",
|
||||
hubUrl,
|
||||
"--api-key",
|
||||
`hub-contract-api-key:${token}`,
|
||||
...(execute ? ["--permission", "hub.execute"] : []),
|
||||
]),
|
||||
result: this.runCli(["hub", "connect", hubUrl, "--api-key", `hub-contract-api-key:${token}`]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -535,14 +519,6 @@ 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"] : [])]) };
|
||||
}
|
||||
@@ -614,7 +590,6 @@ export class HubRelationshipHarness {
|
||||
requestId: "browser-hub-connect",
|
||||
hubUrl: HUB_ORIGIN,
|
||||
token: "browser-token",
|
||||
permissions: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -631,8 +606,8 @@ export class HubRelationshipHarness {
|
||||
this.remote.completeEnrollment();
|
||||
}
|
||||
|
||||
returnEnrollmentPermissions(permissions: string[]): void {
|
||||
this.remote.returnEnrollmentPermissions(permissions);
|
||||
returnEnrollmentScopes(scopes: string[]): void {
|
||||
this.remote.returnEnrollmentScopes(scopes);
|
||||
}
|
||||
|
||||
loseEnrollmentResponse(): void {
|
||||
@@ -677,18 +652,7 @@ export class HubRelationshipHarness {
|
||||
|
||||
connectLatestSocket(): void {
|
||||
const socket = this.latestSocket();
|
||||
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");
|
||||
socket.events.connected(socket.socket);
|
||||
}
|
||||
|
||||
rejectLatestSocket(statusCode: 401 | 403): void {
|
||||
@@ -712,13 +676,7 @@ 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, "session-v1");
|
||||
socket.socket.receiveEnvelope({
|
||||
type: "hello",
|
||||
clientId: `hub:${socket.input.daemonId}`,
|
||||
clientType: "hub",
|
||||
protocolVersion: 1,
|
||||
});
|
||||
socket.events.connected(socket.socket);
|
||||
}
|
||||
|
||||
closeSocket(index: number, code: number): void {
|
||||
@@ -1185,16 +1143,6 @@ 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({
|
||||
@@ -1286,10 +1234,7 @@ export class HubRelationshipHarness {
|
||||
}
|
||||
|
||||
enrollmentAttempts(): HubEnrollment[] {
|
||||
return this.remote.enrollments.map((input) => ({
|
||||
...input,
|
||||
permissions: input.permissions.slice(),
|
||||
}));
|
||||
return this.remote.enrollments.map((input) => ({ ...input, scopes: input.scopes.slice() }));
|
||||
}
|
||||
|
||||
enrollmentInvocation(index = 0): RelationshipInvocationSnapshot {
|
||||
|
||||
@@ -1911,33 +1911,6 @@ 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) ??
|
||||
@@ -2368,7 +2341,6 @@ 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);
|
||||
@@ -2663,9 +2635,6 @@ 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,21 +617,6 @@ 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,7 +49,6 @@ 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>;
|
||||
@@ -244,28 +243,6 @@ 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;
|
||||
@@ -414,7 +391,6 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
flushBootstrapped,
|
||||
clearSubscription,
|
||||
hasSubscription,
|
||||
includesLiveAgent,
|
||||
forwardLiveAgent,
|
||||
emitStoredRecord,
|
||||
removeAgent,
|
||||
|
||||
@@ -110,8 +110,7 @@ export class DaemonSession {
|
||||
type:
|
||||
| "hub.management.daemon.connect.request"
|
||||
| "hub.management.daemon.get_status.request"
|
||||
| "hub.management.daemon.disconnect.request"
|
||||
| "hub.management.daemon.permissions.update.request";
|
||||
| "hub.management.daemon.disconnect.request";
|
||||
}
|
||||
>,
|
||||
): Promise<void> {
|
||||
@@ -121,7 +120,6 @@ 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",
|
||||
@@ -129,17 +127,6 @@ 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,13 +169,11 @@ function createSessionWithActivity(
|
||||
appVisible: boolean;
|
||||
appVisibilityChangedAt?: Date;
|
||||
} | null,
|
||||
subscribed = true,
|
||||
) {
|
||||
return {
|
||||
getClientActivity: vi.fn(() => activity),
|
||||
supports: () => false,
|
||||
supportsForSource: () => false,
|
||||
subscribesToAgent: vi.fn(async () => subscribed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,12 +186,11 @@ function connectClient(
|
||||
appVisible: boolean;
|
||||
appVisibilityChangedAt?: Date;
|
||||
} | null,
|
||||
options: { subscribed?: boolean } = {},
|
||||
) {
|
||||
const ws = createOpenSocket();
|
||||
asInternals<WebSocketServerInternals>(server).sessions.set(ws, {
|
||||
kind: "trusted",
|
||||
session: createSessionWithActivity(activity, options.subscribed ?? true),
|
||||
session: createSessionWithActivity(activity),
|
||||
clientId: "client-test",
|
||||
appVersion: null,
|
||||
connectionLogger: createLogger(),
|
||||
@@ -219,30 +216,6 @@ 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,13 +56,6 @@ 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,
|
||||
@@ -103,7 +96,7 @@ vi.mock("./push/index.js", () => ({
|
||||
|
||||
import { z } from "zod";
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server";
|
||||
import { DAEMON_PERMISSIONS, parseServerInfoStatusPayload } from "./messages.js";
|
||||
import { parseServerInfoStatusPayload } from "./messages.js";
|
||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
|
||||
interface WebSocketServerInternals {
|
||||
@@ -683,28 +676,6 @@ 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();
|
||||
@@ -1011,7 +982,6 @@ 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,13 +192,12 @@ function createOpenSocket() {
|
||||
};
|
||||
}
|
||||
|
||||
function connectClient(server: VoiceAssistantWebSocketServer, subscribed = true) {
|
||||
function connectClient(server: VoiceAssistantWebSocketServer) {
|
||||
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,
|
||||
@@ -275,25 +274,6 @@ 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);
|
||||
|
||||
@@ -98,7 +98,11 @@ 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, type DaemonPermission } from "./authorization/index.js";
|
||||
import {
|
||||
OWNER_PERMISSIONS,
|
||||
permissionsForLegacyHubScopes,
|
||||
type DaemonPermission,
|
||||
} from "./authorization/index.js";
|
||||
import type { WorkspaceLabelService } from "./workspace-labels/index.js";
|
||||
import {
|
||||
APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS,
|
||||
@@ -113,28 +117,20 @@ import {
|
||||
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
|
||||
|
||||
export interface ExternalSocketMetadata {
|
||||
transport: "relay" | "hub";
|
||||
transport: "relay";
|
||||
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" | "hub";
|
||||
transport: "direct" | "relay";
|
||||
peer: "loopback" | "local_ipc" | "external";
|
||||
browserOrigin: boolean;
|
||||
host?: string;
|
||||
@@ -142,7 +138,6 @@ interface WebSocketConnectionIdentity {
|
||||
userAgent?: string;
|
||||
remoteAddress?: string;
|
||||
relayConnectionId?: string;
|
||||
hubDaemonId?: string;
|
||||
clientId?: string;
|
||||
sessionId?: string;
|
||||
appVersion?: string;
|
||||
@@ -438,10 +433,9 @@ export interface WebSocketLike {
|
||||
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
interface SessionConnectionBase {
|
||||
interface TrustedSessionConnectionBase {
|
||||
kind: "trusted";
|
||||
session: Session;
|
||||
principalId: string;
|
||||
sessionKey: string;
|
||||
clientId: string;
|
||||
appVersion: string | null;
|
||||
clientCapabilities: Record<string, unknown> | null;
|
||||
@@ -449,17 +443,37 @@ interface SessionConnectionBase {
|
||||
sockets: Set<WebSocketLike>;
|
||||
}
|
||||
|
||||
interface ReconnectableSessionConnection extends SessionConnectionBase {
|
||||
interface ReconnectableSessionConnection extends TrustedSessionConnectionBase {
|
||||
lifecycle: "reconnectable";
|
||||
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
interface PluginSessionConnection extends SessionConnectionBase {
|
||||
interface PluginSessionConnection extends TrustedSessionConnectionBase {
|
||||
lifecycle: "ephemeral-plugin";
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
|
||||
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;
|
||||
|
||||
interface BrowserToolsRegistration {
|
||||
capabilitySignature: string;
|
||||
@@ -497,10 +511,6 @@ 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() {
|
||||
@@ -919,27 +929,26 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
public broadcast(message: WSOutboundMessage): void {
|
||||
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);
|
||||
const trustedSockets = [...this.sessions]
|
||||
.filter(([, connection]) => connection.kind === "trusted")
|
||||
.map(([ws]) => ws);
|
||||
this.sendMessageToSockets(trustedSockets, message);
|
||||
}
|
||||
|
||||
public listSessions(): Session[] {
|
||||
public listTrustedSessions(): Session[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[...this.sessions.values(), ...this.externalSessionsByKey.values()].map(
|
||||
(connection) => connection.session,
|
||||
),
|
||||
[...this.sessions.values(), ...this.externalSessionsByKey.values()]
|
||||
.filter(
|
||||
(connection): connection is TrustedSessionConnection => connection.kind === "trusted",
|
||||
)
|
||||
.map((connection) => connection.session),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public publishProjectUpdate(update: ProjectUpdate): void {
|
||||
for (const session of this.listSessions()) {
|
||||
for (const session of this.listTrustedSessions()) {
|
||||
void session
|
||||
.emitProjectUpdate(update)
|
||||
.catch((error) => this.logger.warn({ err: error }, "Failed to publish project update"));
|
||||
@@ -962,13 +971,11 @@ 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, false, admission, initialHello);
|
||||
await this.attachSocket(ws, undefined, metadata);
|
||||
}
|
||||
|
||||
public async attachPluginSocket(
|
||||
@@ -994,21 +1001,42 @@ export class VoiceAssistantWebSocketServer {
|
||||
return { closed };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1057,13 +1085,18 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
for (const connection of uniqueConnections) {
|
||||
if (connection.lifecycle === "reconnectable" && connection.externalDisconnectCleanupTimeout) {
|
||||
if (
|
||||
connection.kind === "trusted" &&
|
||||
connection.lifecycle === "reconnectable" &&
|
||||
connection.externalDisconnectCleanupTimeout
|
||||
) {
|
||||
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||
connection.externalDisconnectCleanupTimeout = null;
|
||||
}
|
||||
|
||||
cleanupPromises.push(Promise.resolve(connection.session.cleanup()));
|
||||
for (const ws of connection.sockets) {
|
||||
const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket];
|
||||
for (const ws of sockets) {
|
||||
cleanupPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
// WebSocket.CLOSED = 3
|
||||
@@ -1223,10 +1256,14 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
|
||||
this.sendMessageToSockets(connection.sockets, message);
|
||||
const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket];
|
||||
this.sendMessageToSockets(sockets, message);
|
||||
}
|
||||
|
||||
private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void {
|
||||
if (connection.kind !== "trusted") {
|
||||
return;
|
||||
}
|
||||
for (const ws of connection.sockets) {
|
||||
this.sendBinaryToClient(ws, frame);
|
||||
}
|
||||
@@ -1237,8 +1274,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
request?: unknown,
|
||||
metadata?: ExternalSocketMetadata,
|
||||
allowDuringStartup = false,
|
||||
admission: SessionAdmission = OWNER_SESSION_ADMISSION,
|
||||
initialHello?: WSHelloMessage,
|
||||
): Promise<void> {
|
||||
if (
|
||||
this.connectionLifecycle === "stopping" ||
|
||||
@@ -1261,7 +1296,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
connectionLogger,
|
||||
helloTimeout: null,
|
||||
identity,
|
||||
admission,
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pendingConnections.get(ws) !== pending) {
|
||||
@@ -1293,9 +1327,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
},
|
||||
"Client connected; awaiting hello",
|
||||
);
|
||||
if (initialHello) {
|
||||
this.handleHello({ ws, message: initialHello, pending });
|
||||
}
|
||||
}
|
||||
|
||||
private createSessionConnection(params: {
|
||||
@@ -1305,17 +1336,15 @@ export class VoiceAssistantWebSocketServer {
|
||||
clientCapabilities: Record<string, unknown> | null;
|
||||
connectionLogger: pino.Logger;
|
||||
lifecycle: { kind: "reconnectable" } | { kind: "ephemeral-plugin"; pluginId: string };
|
||||
admission: SessionAdmission;
|
||||
}): SessionConnection {
|
||||
const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle, admission } =
|
||||
params;
|
||||
let connection: SessionConnection | null = null;
|
||||
}): TrustedSessionConnection {
|
||||
const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle } = params;
|
||||
let connection: TrustedSessionConnection | null = null;
|
||||
|
||||
const session = this.createSocketSession({
|
||||
clientId,
|
||||
appVersion,
|
||||
clientCapabilities,
|
||||
permissions: admission.permissions,
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
connectionLogger,
|
||||
onMessage: (msg) => {
|
||||
if (!connection) {
|
||||
@@ -1360,14 +1389,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
onLifecycleIntent: (intent) => {
|
||||
this.onLifecycleIntent?.(intent);
|
||||
},
|
||||
hubExecutionAgents: admission.hubExecutionAgents,
|
||||
hubRelationships: this.hubRelationships ?? undefined,
|
||||
});
|
||||
|
||||
const base: SessionConnectionBase = {
|
||||
const base: TrustedSessionConnectionBase = {
|
||||
kind: "trusted",
|
||||
session,
|
||||
principalId: admission.principalId,
|
||||
sessionKey: sessionConnectionKey(admission.principalId, clientId),
|
||||
clientId,
|
||||
appVersion,
|
||||
clientCapabilities,
|
||||
@@ -1397,7 +1424,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
logger: options.connectionLogger.child({ module: "session" }),
|
||||
onWorkspaceRecovered: async (workspace) => {
|
||||
await Promise.all(
|
||||
this.listSessions().map((activeSession) =>
|
||||
this.listTrustedSessions().map((activeSession) =>
|
||||
activeSession.refreshRecoveredWorkspaceForExternalMutation(workspace),
|
||||
),
|
||||
);
|
||||
@@ -1537,8 +1564,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
if (message.appVersion) {
|
||||
pending.identity.appVersion = message.appVersion;
|
||||
}
|
||||
const sessionKey = sessionConnectionKey(pending.admission.principalId, clientId);
|
||||
const existing = pluginId ? undefined : this.externalSessionsByKey.get(sessionKey);
|
||||
const existing = pluginId ? undefined : this.externalSessionsByKey.get(clientId);
|
||||
if (existing) {
|
||||
this.resumeSession({ ws, message, pending, existing });
|
||||
return;
|
||||
@@ -1553,15 +1579,14 @@ 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(sessionKey, connection);
|
||||
this.externalSessionsByKey.set(clientId, connection);
|
||||
}
|
||||
pending.identity.sessionId = connection.session.getSessionId();
|
||||
this.syncBrowserToolsClientRegistration(connection);
|
||||
this.sendToClient(ws, this.createServerInfoMessage(connection.session));
|
||||
this.sendToClient(ws, this.createServerInfoMessage());
|
||||
connection.connectionLogger.info(
|
||||
{
|
||||
...toConnectionLogFields(pending.identity),
|
||||
@@ -1605,7 +1630,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.sessions.set(ws, existing);
|
||||
pending.identity.sessionId = existing.session.getSessionId();
|
||||
this.syncBrowserToolsClientRegistration(existing);
|
||||
this.sendToClient(ws, this.createServerInfoMessage(existing.session));
|
||||
this.sendToClient(ws, this.createServerInfoMessage());
|
||||
pending.connectionLogger.info(
|
||||
{
|
||||
...toConnectionLogFields(pending.identity),
|
||||
@@ -1616,13 +1641,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload {
|
||||
private buildServerInfoStatusPayload(): 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 } : {}),
|
||||
@@ -1761,12 +1785,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
};
|
||||
}
|
||||
|
||||
private createServerInfoMessage(session: Session): WSOutboundMessage {
|
||||
private createServerInfoMessage(): WSOutboundMessage {
|
||||
return {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: this.buildServerInfoStatusPayload(session),
|
||||
payload: this.buildServerInfoStatusPayload(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1782,9 +1806,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
private broadcastCapabilitiesUpdate(): void {
|
||||
for (const connection of new Set(this.sessions.values())) {
|
||||
this.sendToConnection(connection, this.createServerInfoMessage(connection.session));
|
||||
}
|
||||
this.broadcast(this.createServerInfoMessage());
|
||||
}
|
||||
|
||||
private broadcastDaemonConfigChanged(config: MutableDaemonConfig): void {
|
||||
@@ -1870,12 +1892,22 @@ 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);
|
||||
this.unregisterBrowserToolsClient(connection.clientId);
|
||||
if (connection.lifecycle === "ephemeral-plugin") {
|
||||
this.pluginSocketIds.delete(ws);
|
||||
await this.cleanupConnection(connection, "Plugin session disconnected");
|
||||
@@ -1933,7 +1965,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
private async cleanupConnection(
|
||||
connection: SessionConnection,
|
||||
connection: TrustedSessionConnection,
|
||||
logMessage: string,
|
||||
): Promise<void> {
|
||||
this.incrementRuntimeCounter("sessionCleanup");
|
||||
@@ -1948,12 +1980,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
connection.sockets.clear();
|
||||
if (connection.lifecycle === "reconnectable") {
|
||||
const existing = this.externalSessionsByKey.get(connection.sessionKey);
|
||||
const existing = this.externalSessionsByKey.get(connection.clientId);
|
||||
if (existing === connection) {
|
||||
this.externalSessionsByKey.delete(connection.sessionKey);
|
||||
this.externalSessionsByKey.delete(connection.clientId);
|
||||
}
|
||||
}
|
||||
this.unregisterBrowserToolsClient(connection);
|
||||
this.unregisterBrowserToolsClient(connection.clientId);
|
||||
|
||||
connection.connectionLogger.trace(
|
||||
{ clientId: connection.clientId, totalSessions: this.sessions.size },
|
||||
@@ -1962,51 +1994,45 @@ export class VoiceAssistantWebSocketServer {
|
||||
await connection.session.cleanup();
|
||||
}
|
||||
|
||||
private syncBrowserToolsClientRegistration(connection: SessionConnection): void {
|
||||
private syncBrowserToolsClientRegistration(connection: TrustedSessionConnection): 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(registrationKey);
|
||||
this.unregisterBrowserToolsClient(connection.clientId);
|
||||
return;
|
||||
}
|
||||
const capabilitySignature = JSON.stringify(browserHostCapability);
|
||||
const existing = this.browserToolsRegistrations.get(registrationKey);
|
||||
const existing = this.browserToolsRegistrations.get(connection.clientId);
|
||||
if (existing?.capabilitySignature === capabilitySignature) {
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
this.browserToolsRegistrations.delete(registrationKey);
|
||||
this.browserToolsRegistrations.delete(connection.clientId);
|
||||
existing.unregister();
|
||||
}
|
||||
|
||||
const unregister = this.browserToolsBroker.registerClient({
|
||||
id: connection.principalId === "owner" ? connection.clientId : registrationKey,
|
||||
id: connection.clientId,
|
||||
hostKind: browserHostCapability.hostKind,
|
||||
supportedCommands: browserHostCapability.supportedCommands,
|
||||
sendBrowserAutomationRequest: (request) => {
|
||||
this.sendToConnection(connection, wrapSessionMessage(request));
|
||||
},
|
||||
});
|
||||
this.browserToolsRegistrations.set(registrationKey, {
|
||||
this.browserToolsRegistrations.set(connection.clientId, {
|
||||
capabilitySignature,
|
||||
unregister,
|
||||
});
|
||||
}
|
||||
|
||||
private unregisterBrowserToolsClient(connection: SessionConnection | string): void {
|
||||
const registrationKey = typeof connection === "string" ? connection : connection.sessionKey;
|
||||
const registration = this.browserToolsRegistrations.get(registrationKey);
|
||||
private unregisterBrowserToolsClient(clientId: string): void {
|
||||
const registration = this.browserToolsRegistrations.get(clientId);
|
||||
if (!registration) {
|
||||
return;
|
||||
}
|
||||
this.browserToolsRegistrations.delete(registrationKey);
|
||||
this.browserToolsRegistrations.delete(clientId);
|
||||
registration.unregister();
|
||||
}
|
||||
|
||||
@@ -2044,7 +2070,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
log.warn(
|
||||
{
|
||||
clientId: activeConnection?.clientId,
|
||||
clientId: activeConnection?.kind === "trusted" ? activeConnection.clientId : undefined,
|
||||
requestId: requestInfo?.requestId,
|
||||
requestType: requestInfo?.requestType,
|
||||
error: parsedMessage.error.message,
|
||||
@@ -2109,6 +2135,11 @@ 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({
|
||||
@@ -2257,8 +2288,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
let connectionFields: Record<string, unknown>;
|
||||
if (identity) {
|
||||
connectionFields = toConnectionLogFields(identity);
|
||||
} else {
|
||||
} else if (activeConnection.kind === "trusted") {
|
||||
connectionFields = { clientId: activeConnection.clientId };
|
||||
} else {
|
||||
connectionFields = { daemonId: activeConnection.daemonId };
|
||||
}
|
||||
activeConnection.connectionLogger.warn(
|
||||
{
|
||||
@@ -2268,11 +2301,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
"ws_control_rpc_received",
|
||||
);
|
||||
}
|
||||
if (message.message.type === "browser.automation.execute.response") {
|
||||
if (!activeConnection.session.allowsInbound(message.message)) {
|
||||
await activeConnection.session.handleMessage(message.message, ws);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeConnection.kind === "trusted" &&
|
||||
message.message.type === "browser.automation.execute.response"
|
||||
) {
|
||||
this.browserToolsBroker?.receiveResponse(message.message as BrowserAutomationExecuteResponse);
|
||||
return;
|
||||
}
|
||||
@@ -2282,7 +2314,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.recordRequestLatency(message.message.type, durationMs);
|
||||
|
||||
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) {
|
||||
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS && activeConnection.kind === "trusted") {
|
||||
activeConnection.connectionLogger.warn(
|
||||
{
|
||||
requestType: message.message.type,
|
||||
@@ -2394,7 +2426,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics {
|
||||
const uniqueConnections = new Set<SessionConnection>(this.externalSessionsByKey.values());
|
||||
const uniqueConnections = new Set<TrustedSessionConnection>(
|
||||
this.externalSessionsByKey.values(),
|
||||
);
|
||||
let terminalDirectorySubscriptionCount = 0;
|
||||
let terminalSubscriptionCount = 0;
|
||||
let workspaceGitWatchedDirectoryCount = 0;
|
||||
@@ -2503,17 +2537,15 @@ 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 (!(await connection.session.subscribesToAgent(agent))) continue;
|
||||
if (connection.kind !== "trusted") {
|
||||
continue;
|
||||
}
|
||||
clientEntries.push({
|
||||
ws,
|
||||
state: this.getClientActivityState(connection.session),
|
||||
@@ -2522,6 +2554,10 @@ 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,
|
||||
@@ -2596,12 +2632,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}> = [];
|
||||
|
||||
for (const [ws, connection] of this.sessions) {
|
||||
if (
|
||||
!(await connection.session.subscribesToTerminalDirectory({
|
||||
cwd: params.cwd,
|
||||
...(params.workspaceId ? { workspaceId: params.workspaceId } : {}),
|
||||
}))
|
||||
) {
|
||||
if (connection.kind !== "trusted") {
|
||||
continue;
|
||||
}
|
||||
clientEntries.push({
|
||||
@@ -2677,7 +2708,7 @@ function createWebSocketConnectionIdentity(
|
||||
): WebSocketConnectionIdentity {
|
||||
return {
|
||||
connectionId: `conn_${randomUUID().replaceAll("-", "")}`,
|
||||
transport: metadata?.transport ?? "direct",
|
||||
transport: metadata?.transport === "relay" ? "relay" : "direct",
|
||||
peer: resolveConnectionPeer(requestMetadata, metadata),
|
||||
browserOrigin: requestMetadata.origin !== undefined,
|
||||
...(requestMetadata.host ? { host: requestMetadata.host } : {}),
|
||||
@@ -2685,14 +2716,9 @@ 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,
|
||||
@@ -2703,7 +2729,6 @@ 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 } : {}),
|
||||
@@ -2714,7 +2739,7 @@ function resolveConnectionPeer(
|
||||
requestMetadata: SocketRequestMetadata,
|
||||
metadata: ExternalSocketMetadata | undefined,
|
||||
): WebSocketConnectionIdentity["peer"] {
|
||||
if (metadata !== undefined) return "external";
|
||||
if (metadata?.transport === "relay") return "external";
|
||||
if (!requestMetadata.remoteAddress) return "local_ipc";
|
||||
return isLoopbackAddress(requestMetadata.remoteAddress) ? "loopback" : "external";
|
||||
}
|
||||
|
||||
@@ -617,23 +617,11 @@ 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,19 +178,6 @@ 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,95 +1,15 @@
|
||||
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>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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
@@ -1,201 +0,0 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
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’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 “The control plane for coding agents.”</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’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’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’s live project and workspace state, so the radar
|
||||
doesn’t need a separate service.
|
||||
</Para>
|
||||
<PluginToolGroup calls={PLUGIN_BUILD_CALLS} />
|
||||
<Para>The surface is wired. I’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>
|
||||
);
|
||||
}
|
||||
@@ -1,778 +0,0 @@
|
||||
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" },
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user