Compare commits

...

22 Commits

Author SHA1 Message Date
Mohamed Boudra
5aecd36194 chore(release): cut 0.1.89 2026-06-02 20:51:07 +07:00
Mohamed Boudra
539441b3d5 Add 0.1.89 changelog 2026-06-02 20:49:48 +07:00
Mohamed Boudra
4b9eff1759 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-02 20:31:09 +07:00
Mohamed Boudra
48bc2f3166 Bump Cline ACP catalog entry to v3 2026-06-02 20:30:51 +07:00
Mohamed Boudra
14af053c66 Keep delegated agents out of workspace alerts (#1293)
* Keep delegated agents out of workspace alerts

* Share delegated agent label parsing
2026-06-02 20:55:19 +08:00
Mohamed Boudra
4f3116d28a Unify workspace service URLs (#1288)
* Unify workspace service proxying

* Clean up service proxy review issues
2026-06-02 19:49:58 +08:00
Mohamed Boudra
5666b3014a Fix Pi extension command turns (#1290) 2026-06-02 10:09:50 +00:00
Mohamed Boudra
07ace19b69 Keep fork PR badges visible in worktrees 2026-06-02 15:38:27 +07:00
Matt Cowger
f1a20a9fe9 Add configurable service proxy (#1280)
* Add configurable service proxy

* Fix service proxy URL used when opening scripts with a public base URL

When a public proxy URL was configured, the View link was falling through
to direct connection logic and opening the wrong URL. Move the public URL
early-return before connection-type checks, and remove the now-dead
duplicate check further down.

* Add service proxy documentation

Covers config options, URL generation format, wildcard DNS setup,
and reverse proxy Host header requirements.

* Cap combined public service label and document hostname format

* Address service proxy review feedback
2026-06-02 14:08:41 +08:00
Mohamed Boudra
eac5d93f78 Tune hero mockup sidebar, composer, and tabs 2026-06-01 23:39:02 +07:00
Mohamed Boudra
541e4c04cf Lower compact composer breakpoint to 500, add footer bottom padding 2026-06-01 22:29:58 +07:00
Mohamed Boudra
ce8ad8c68f Let composer controls adapt to narrow panes (#1270) 2026-06-01 22:58:03 +08:00
Éverton Toffanetto
1ede1bccf7 feat(desktop): persist and restore window state
Fixes #1223
2026-06-01 12:04:58 +00:00
Mohamed Boudra
a5c2f39f65 Tighten social proof marquee spacing 2026-06-01 18:04:20 +07:00
Mohamed Boudra
a807eb0eb2 Update homepage messaging and carousel performance 2026-06-01 16:30:17 +07:00
Matt Cowger
d406c29e16 Add configurable worktree root
* Add configurable worktree root

Allow new Paseo worktrees to be created under a custom worktrees.root from config.json while preserving the existing PASEO_HOME/worktrees default. Thread the resolved root through create/list/archive/ownership flows, MCP/session paths, checkout metadata, schema, docs, and targeted tests.

* Address worktree root review feedback

Use the resolved Paseo worktree base when a custom PASEO_HOME is provided, and remove an unused worktreesRoot dependency from worktree list handling.

* Fix checkout worktree path test on Windows

Use platform-native paths in the custom PASEO_HOME regression test so Windows CI compares against the same resolved root shape as production code.

* Clarify optional worktrees config docs

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-01 16:19:29 +08:00
Geoffrey Marc
8c6abcb41f fix(app): render bold/italic/strikethrough and line breaks via UITextView span on iOS (#1254)
* fix(app): render bold/italic/strikethrough via UITextView span on iOS

strong/em/s had no custom render rule and fell back to
react-native-markdown-display's defaults, which wrap children in a plain
RN <Text>. On iOS the paragraph/textgroup are native UITextViews
(markdown-text.ios.tsx); a plain <Text> nested inside is not hoisted into
a UITextViewChild, so its content rendered invisibly — bold/italic/
strikethrough silently disappeared (regression from #1153, v0.1.84).

Route strong/em/s through MarkdownInheritedText -> MarkdownTextSpan, the
same path text/textgroup already use, so the styled run composes as a
UITextViewChild on iOS and stays visible + selectable. Web/Android use a
plain <Text> for MarkdownTextSpan, so behavior there is unchanged.

Fixes #1253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): render hard/soft line breaks via UITextView span on iOS

hardbreak/softbreak had no custom rule, so react-native-markdown-display
emitted a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
plain <Text> is dropped (same root cause as strong/em/s/link): on iOS
hard line breaks vanished, and a softbreak between words joined them
("one\ntwo" -> "onetwo") — the "adjacent words dropped" symptom from the
original report.

Route both through MarkdownTextSpan so the newline composes as a
UITextViewChild on iOS; web/Android keep the same "\n".

Refs #1253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:57:06 +08:00
Matt Cowger
b8ccd543c4 fix: archive worktrees even when teardown fails
* Archive workspace record even if teardown fails

* Fix teardown test for cross-platform commands
2026-06-01 15:34:51 +08:00
Éverton Toffanetto
ae5dfc0b3c Fix split-pane right-edge resize clipping (#1261)
* fix(app): clamp split pane resize

Prevent right-edge drags from overflowing the Electron viewport.

* fix(app): isolate resize pointer state

Keep concurrent resize drags from clobbering each other.
2026-06-01 15:32:26 +08:00
Mohamed Boudra
539d2969f1 Add homepage social proof 2026-06-01 14:06:12 +07:00
Mohamed Boudra
7fb3dda20c Add detached agents and heartbeat scheduling (#1266) 2026-06-01 14:07:31 +08:00
paseo-ai[bot]
89ec358d00 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-01 04:31:01 +00:00
125 changed files with 5800 additions and 1800 deletions

View File

@@ -1,5 +1,28 @@
# Changelog
## 0.1.89 - 2026-06-02
### Added
- **Open workspace services through public service proxy links** ([#1280](https://github.com/getpaseo/paseo/pull/1280) by [@mcowger](https://github.com/mcowger))
- **Choose where new worktrees are created** ([#1230](https://github.com/getpaseo/paseo/pull/1230) by [@mcowger](https://github.com/mcowger))
- **Desktop windows reopen at the same size and position** ([#1224](https://github.com/getpaseo/paseo/pull/1224) by [@everton-dgn](https://github.com/everton-dgn))
- **Delegated agents can run independently and send recurring heartbeat updates**
### Improved
- Composer controls fit better in narrow panes
- Fork pull request badges stay visible in worktrees
- Cline in the ACP catalog is updated to v3
### Fixed
- Archiving a worktree finishes even if teardown hits an error ([#1260](https://github.com/getpaseo/paseo/pull/1260) by [@mcowger](https://github.com/mcowger))
- iOS chat messages render bold, italics, strikethrough, and line breaks correctly ([#1254](https://github.com/getpaseo/paseo/pull/1254) by [@outofrange-consulting](https://github.com/outofrange-consulting))
- Right-edge split pane resizing no longer clips ([#1261](https://github.com/getpaseo/paseo/pull/1261) by [@everton-dgn](https://github.com/everton-dgn))
- Pi extension command output no longer hangs
- Delegated agents no longer appear in workspace alert counts
## 0.1.88 - 2026-06-01
### Added

View File

@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |

View File

@@ -14,14 +14,14 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
## Relationships
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive
@@ -77,12 +77,6 @@ We considered universal decoupling (no tab close ever archives, archive is alway
## Limitations
### Detached agents are cascade-archived
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
@@ -99,11 +93,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | ------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.

View File

@@ -147,6 +147,9 @@ Single file, validated with `PersistedConfigSchema`.
app: {
baseUrl: string
},
worktrees?: {
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }

View File

@@ -158,12 +158,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
| Variable | Value |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:

View File

@@ -18,7 +18,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.

View File

@@ -232,18 +232,16 @@ To confirm the submission landed, inspect the EAS workflow with `npx eas workflo
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
Pattern:
```jsonc
// mcp__paseo__create_schedule arguments
// mcp__paseo__create_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"every": "15m",
"cron": "*/15 * * * *",
"maxRuns": 8, // covers ~2h of build + store-submission window
"target": "self", // heartbeat, NOT "new-agent"
"cwd": "/path/to/paseo",
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
}
```

92
docs/service-proxy.md Normal file
View File

@@ -0,0 +1,92 @@
# Service Proxy
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
The generated hostname is built from the script name, branch, and project:
```
<script>--<branch>--<project>.localhost
```
If the branch is `main` or `master`, the branch segment is omitted:
```
<script>--<project>.localhost
```
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
```
dev--feature-auth--miniweb.localhost
```
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
```json
{
"version": 1,
"daemon": {
"serviceProxy": {
"listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com"
}
}
}
```
| Field | Required | Description |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
## DNS and reverse proxy setup
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
1. Configure a wildcard DNS record:
```
*.paseoapps.my.domain.com → 10.1.1.1
```
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
Nginx example:
```nginx
server {
listen 443 ssl;
server_name *.paseoapps.my.domain.com;
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
}
}
```
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
| Variable | Description |
| ------------------------------------- | ------------------------------------------------------------------------- |
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |

View File

@@ -1 +1 @@
sha256-vz+c4jwakEd0nBtsR4mmBk8eq9WYVqqQxXOx2tHKSUA=
sha256-R2EUXh8wV0bjdeDNWMhFzd3iDV/dpkLUikbX7nzoMXQ=

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.88",
"version": "0.1.89",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.88",
"version": "0.1.89",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36953,7 +36953,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.88",
"version": "0.1.89",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -37178,12 +37178,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.88",
"version": "0.1.89",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.88",
"@getpaseo/protocol": "0.1.88",
"@getpaseo/server": "0.1.88",
"@getpaseo/client": "0.1.89",
"@getpaseo/protocol": "0.1.89",
"@getpaseo/server": "0.1.89",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37429,10 +37429,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.88",
"version": "0.1.89",
"dependencies": {
"@getpaseo/protocol": "0.1.88",
"@getpaseo/relay": "0.1.88",
"@getpaseo/protocol": "0.1.89",
"@getpaseo/relay": "0.1.89",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -37452,7 +37452,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.88",
"version": "0.1.89",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37704,7 +37704,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.88",
"version": "0.1.89",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37740,7 +37740,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.88",
"version": "0.1.89",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37971,7 +37971,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.88",
"version": "0.1.89",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37992,7 +37992,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.88",
"version": "0.1.89",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38210,14 +38210,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.88",
"version": "0.1.89",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.88",
"@getpaseo/highlight": "0.1.88",
"@getpaseo/protocol": "0.1.88",
"@getpaseo/relay": "0.1.88",
"@getpaseo/client": "0.1.89",
"@getpaseo/highlight": "0.1.89",
"@getpaseo/protocol": "0.1.89",
"@getpaseo/relay": "0.1.89",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38989,7 +38989,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.88",
"version": "0.1.89",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.88",
"version": "0.1.89",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.88",
"version": "0.1.89",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -17,14 +17,18 @@
<title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
/* These styles make the body full-height */
/* Keep the app shell fixed to the viewport. */
html,
body {
width: 100%;
height: 100%;
}
/* These styles disable body scrolling if you are using <ScrollView> */
body {
margin: 0;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
}
/* These styles smooth text rendering in the app shell. */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -38,8 +42,12 @@
/* These styles make the root element full-height */
#root {
display: flex;
width: 100%;
height: 100%;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
</style>
<style>

View File

@@ -1622,6 +1622,67 @@ export const AssistantMessage = memo(function AssistantMessage({
{children}
</MarkdownInheritedText>
),
// strong/em/s have no custom rule in react-native-markdown-display's
// defaults beyond wrapping children in a plain RN <Text>. On iOS the
// paragraph/textgroup are native UITextViews (see markdown-text.ios.tsx),
// and a plain <Text> nested inside one is not hoisted into a
// UITextViewChild, so its content renders invisibly. Route these inline
// marks through MarkdownTextSpan (same path as text/textgroup) so the
// styled content composes and stays visible + selectable on iOS.
strong: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.strong}
>
{children}
</MarkdownInheritedText>
),
em: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.em}
>
{children}
</MarkdownInheritedText>
),
s: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.s}
>
{children}
</MarkdownInheritedText>
),
// hardbreak/softbreak fall back to react-native-markdown-display's
// default, a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
// Android keep the same "\n" they rendered before.
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
code_block: (
node: ASTNode,
_children: ReactNode[],

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
describe("computeResizeHandleSizes", () => {
it("clamps right-edge drags to the adjacent pane minimum", () => {
const sizes = computeResizeHandleSizes({
sizes: [0.25, 0.5, 0.25],
index: 1,
deltaRatio: 0.5,
});
expect(sizes[0]).toBe(0.25);
expect(sizes[1]).toBe(0.65);
expect(sizes[2]).toBeCloseTo(0.1, 10);
});
it("clamps left-edge drags to the adjacent pane minimum", () => {
const sizes = computeResizeHandleSizes({
sizes: [0.25, 0.5, 0.25],
index: 1,
deltaRatio: -0.5,
});
expect(sizes[0]).toBe(0.25);
expect(sizes[1]).toBe(0.1);
expect(sizes[2]).toBeCloseTo(0.65, 10);
});
it("moves adjacent pane sizes without clamping", () => {
const sizes = computeResizeHandleSizes({
sizes: [0.25, 0.5, 0.25],
index: 1,
deltaRatio: 0.05,
});
expect(sizes[0]).toBe(0.25);
expect(sizes[1]).toBe(0.55);
expect(sizes[2]).toBeCloseTo(0.2, 10);
});
it("splits tiny adjacent pairs evenly when the configured minimum cannot fit", () => {
expect(
computeResizeHandleSizes({
sizes: [0.45, 0.05, 0.05, 0.45],
index: 1,
deltaRatio: 0.05,
}),
).toEqual([0.45, 0.05, 0.05, 0.45]);
});
it("leaves sizes unchanged when the adjacent pair is invalid", () => {
expect(
computeResizeHandleSizes({
sizes: [0.25, 0.5, 0.25],
index: 3,
deltaRatio: 0.25,
}),
).toEqual([0.25, 0.5, 0.25]);
expect(
computeResizeHandleSizes({
sizes: [0.25, 0, 0, 0.75],
index: 1,
deltaRatio: 0.25,
}),
).toEqual([0.25, 0, 0, 0.75]);
});
});

View File

@@ -0,0 +1,36 @@
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
interface ComputeResizeHandleSizesInput {
sizes: number[];
index: number;
deltaRatio: number;
minSize?: number;
}
export function computeResizeHandleSizes({
sizes,
index,
deltaRatio,
minSize = MIN_SPLIT_SIZE,
}: ComputeResizeHandleSizesInput): number[] {
const nextSizes = sizes.slice();
const leftSize = sizes[index];
const rightSize = sizes[index + 1];
if (leftSize === undefined || rightSize === undefined) {
return nextSizes;
}
const pairSize = leftSize + rightSize;
if (pairSize <= 0) {
return nextSizes;
}
const adjacentMinSize = Math.min(minSize, pairSize / 2);
const nextLeftSize = Math.min(
pairSize - adjacentMinSize,
Math.max(adjacentMinSize, leftSize + deltaRatio),
);
nextSizes[index] = nextLeftSize;
nextSizes[index + 1] = pairSize - nextLeftSize;
return nextSizes;
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
export interface ResizeHandleProps {
direction: "horizontal" | "vertical";
@@ -13,8 +14,14 @@ export interface ResizeHandleProps {
interface PointerState {
containerSize: number;
pointerStart: number;
leftSize: number;
rightSize: number;
}
function resetWindowHorizontalScroll() {
// Clamp any browser scroll introduced while dragging past the viewport edge.
if (window.scrollX === 0) {
return;
}
window.scrollTo(0, window.scrollY);
}
export function ResizeHandle({
@@ -25,7 +32,8 @@ export function ResizeHandle({
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const pointerStateRef = useRef<PointerState | null>(null);
const pointerStatesRef = useRef(new Map<number, PointerState>());
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
const [dragging, setDragging] = useState(false);
@@ -34,7 +42,11 @@ export function ResizeHandle({
const handlePointerDown = useCallback(
(event: RNPointerEvent) => {
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null;
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
if (!hitAreaElement) {
return;
}
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
if (!containerElement) {
return;
}
@@ -45,51 +57,83 @@ export function ResizeHandle({
return;
}
const pointerId = event.nativeEvent.pointerId;
if (pointerStatesRef.current.has(pointerId)) {
return;
}
setDragging(true);
pointerStateRef.current = {
pointerStatesRef.current.set(pointerId, {
containerSize,
pointerStart:
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
leftSize: sizes[index] ?? 0,
rightSize: sizes[index + 1] ?? 0,
};
});
const previousCursor = document.body.style.cursor;
if (pointerStatesRef.current.size === 1) {
cursorBeforeDragRef.current = document.body.style.cursor;
}
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
document.body.style.cursor = nextCursor;
event.preventDefault();
event.stopPropagation();
const pointerCaptureElement = hitAreaElement;
pointerCaptureElement.setPointerCapture?.(pointerId);
resetWindowHorizontalScroll();
function cleanup() {
pointerStateRef.current = null;
setDragging(false);
document.body.style.cursor = previousCursor;
pointerStatesRef.current.delete(pointerId);
setDragging(pointerStatesRef.current.size > 0);
if (pointerStatesRef.current.size === 0) {
document.body.style.cursor = cursorBeforeDragRef.current ?? "";
cursorBeforeDragRef.current = null;
}
if (pointerCaptureElement.hasPointerCapture?.(pointerId)) {
pointerCaptureElement.releasePointerCapture(pointerId);
}
resetWindowHorizontalScroll();
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
}
function handlePointerMove(moveEvent: PointerEvent) {
const pointerState = pointerStateRef.current;
if (moveEvent.pointerId !== pointerId) {
return;
}
const pointerState = pointerStatesRef.current.get(pointerId);
if (!pointerState) {
return;
}
moveEvent.preventDefault();
resetWindowHorizontalScroll();
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
const nextSizes = sizes.slice();
nextSizes[index] = pointerState.leftSize + deltaRatio;
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
onResizeSplit(groupId, nextSizes);
onResizeSplit(
groupId,
computeResizeHandleSizes({
sizes,
index,
deltaRatio,
}),
);
}
function handlePointerUp() {
function handlePointerUp(upEvent: PointerEvent) {
if (upEvent.pointerId !== pointerId) {
return;
}
cleanup();
}
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
},
[direction, groupId, index, onResizeSplit, sizes],
);
@@ -130,6 +174,7 @@ export function ResizeHandle({
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
touchAction: "none",
} as object,
],
[direction],

View File

@@ -97,6 +97,7 @@ interface ControlledAgentControlsProps {
/** Extra elements rendered inline with the agent controls (desktop only). */
desktopExtras?: ReactNode;
modelSelectorServerId?: string | null;
isCompactLayout?: boolean;
}
export interface DraftAgentControlsProps {
@@ -124,12 +125,14 @@ export interface DraftAgentControlsProps {
isRetryingModelProvider?: boolean;
disabled?: boolean;
modelSelectorServerId?: string | null;
isCompactLayout?: boolean;
}
interface AgentControlsProps {
agentId: string;
serverId: string;
onDropdownClose?: () => void;
isCompactLayout?: boolean;
}
function findOptionLabel(
@@ -409,9 +412,11 @@ function ControlledAgentControls({
isRetryingModelProvider = false,
desktopExtras,
modelSelectorServerId = null,
isCompactLayout,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
@@ -1345,6 +1350,7 @@ export const AgentControls = memo(function AgentControls({
agentId,
serverId,
onDropdownClose,
isCompactLayout,
}: AgentControlsProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
@@ -1518,8 +1524,15 @@ export const AgentControls = memo(function AgentControls({
);
const modeChip = useMemo(
() => <AgentModeControl serverId={serverId} agentId={agentId} placement="toolbar" />,
[serverId, agentId],
() => (
<AgentModeControl
serverId={serverId}
agentId={agentId}
placement="toolbar"
isCompactLayout={isCompactLayout}
/>
),
[serverId, agentId, isCompactLayout],
);
if (!agent) {
@@ -1548,6 +1561,7 @@ export const AgentControls = memo(function AgentControls({
disabled={!client}
desktopExtras={modeChip}
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
/>
);
});
@@ -1577,9 +1591,11 @@ export function DraftAgentControls({
isRetryingModelProvider = false,
disabled = false,
modelSelectorServerId = null,
isCompactLayout,
}: DraftAgentControlsProps) {
const { preferences, updatePreferences } = useFormPreferences();
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const mappedThinkingOptions = useMemo<AgentControlOption[]>(() => {
return toThinkingControlOptions(thinkingOptions);
@@ -1625,9 +1641,18 @@ export function DraftAgentControls({
selectedMode={selectedMode}
onSelectMode={onSelectMode}
disabled={disabled}
isCompactLayout={isCompactLayout}
/>
),
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
[
selectedProvider,
providerDefinitions,
modeOptions,
selectedMode,
onSelectMode,
disabled,
isCompactLayout,
],
);
if (!isCompact) {
@@ -1661,6 +1686,7 @@ export function DraftAgentControls({
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
desktopExtras={draftModeChip}
isCompactLayout={isCompactLayout}
/>
) : null}
</View>
@@ -1688,6 +1714,7 @@ export function DraftAgentControls({
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
modelSelectorServerId={modelSelectorServerId}
isCompactLayout={isCompactLayout}
/>
);
}

View File

@@ -235,14 +235,17 @@ interface AgentModeControlProps {
serverId: string;
agentId: string;
placement: AgentModeControlPlacement;
isCompactLayout?: boolean;
}
export const AgentModeControl = memo(function AgentModeControl({
serverId,
agentId,
placement,
isCompactLayout,
}: AgentModeControlProps) {
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const slice = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
@@ -303,6 +306,7 @@ export interface DraftAgentModeControlProps {
onSelectMode: (modeId: string) => void;
disabled?: boolean;
placement: AgentModeControlPlacement;
isCompactLayout?: boolean;
}
export function DraftAgentModeControl({
@@ -313,8 +317,10 @@ export function DraftAgentModeControl({
onSelectMode,
disabled,
placement,
isCompactLayout,
}: DraftAgentModeControlProps) {
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
if (!selectedProvider || modeOptions.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (

View File

@@ -4,6 +4,7 @@ import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
@@ -36,7 +37,11 @@ import {
useWorkspaceAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import {
COMPACT_FORM_FACTOR_WIDTH,
MAX_CONTENT_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
@@ -380,7 +385,11 @@ export function WorkspaceDraftAgentTab({
};
}, [pendingAutoSubmit, pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd: composerState.workingDir,
@@ -401,14 +410,14 @@ export function WorkspaceDraftAgentTab({
};
openFileExplorerForCheckout({
checkout,
isCompact,
isCompact: isCompactFormFactor,
});
setExplorerTabForCheckout({
...checkout,
tab: "changes",
});
},
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
);
const {
@@ -625,10 +634,14 @@ export function WorkspaceDraftAgentTab({
);
const composerFooter = useMemo(
() =>
isCompact ? (
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
isCompactComposerLayout ? (
<DraftAgentModeControl
placement="footer"
{...composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
) : undefined,
[isCompact, composerAgentControls],
[isCompactComposerLayout, composerAgentControls],
);
return (
@@ -662,7 +675,7 @@ export function WorkspaceDraftAgentTab({
)}
</View>
<ReanimatedAnimated.View style={inputAreaWrapperStyle}>
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
@@ -692,6 +705,7 @@ export function WorkspaceDraftAgentTab({
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
</View>

View File

@@ -140,6 +140,10 @@ function resolveIsDesktopWebBreakpoint(isMobile: boolean): boolean {
return isWeb && !isMobile;
}
function resolveCompactLayout(override: boolean | undefined, formFactor: boolean): boolean {
return override ?? formFactor;
}
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
}
@@ -223,14 +227,22 @@ interface RenderLeftContentArgs {
agentId: string;
serverId: string;
focusInput: () => void;
isCompactLayout: boolean;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
const { agentControls, agentId, serverId, focusInput } = args;
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} />;
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
}
return <AgentControls agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
return (
<AgentControls
agentId={agentId}
serverId={serverId}
onDropdownClose={focusInput}
isCompactLayout={isCompactLayout}
/>
);
}
interface RenderAttachmentTrayArgs {
@@ -674,6 +686,8 @@ interface ComposerProps {
footer?: ReactNode;
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
externalKeyboardShift?: boolean;
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
isCompactLayout?: boolean;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -869,6 +883,7 @@ export function Composer({
inputWrapperStyle,
footer,
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) {
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
@@ -899,9 +914,11 @@ export function Composer({
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isMobile);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
const userInput = value;
const setUserInput = onChangeText;
const {
@@ -1437,7 +1454,7 @@ export function Composer({
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isProcessing={isProcessing}
isCompact={isMobile}
isCompact={isCompactLayout}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
@@ -1455,7 +1472,7 @@ export function Composer({
hasSendableContent,
isAgentRunning,
isConnected,
isMobile,
isCompactLayout,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitching,
@@ -1475,13 +1492,13 @@ export function Composer({
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isMobile,
isCompactLayout,
),
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isMobile],
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
);
const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isMobile),
[contextWindowMeter, isMobile],
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
[contextWindowMeter, isCompactLayout],
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -1548,8 +1565,8 @@ export function Composer({
);
const leftContent = useMemo(
() => renderLeftContent({ agentControls, agentId, serverId, focusInput }),
[agentId, focusInput, serverId, agentControls],
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
[agentId, focusInput, serverId, agentControls, isCompactLayout],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -1768,6 +1785,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: -theme.spacing[3],
},
alignItems: "center",
paddingBottom: {
xs: 0,
md: theme.spacing[2],
},
},
footerContent: {
width: "100%",

View File

@@ -13,6 +13,7 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
// Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820;
export const COMPACT_FORM_FACTOR_WIDTH = 500;
// Desktop app constants for macOS traffic light buttons
// These buttons (close/minimize/maximize) overlay the top-left corner

View File

@@ -67,10 +67,10 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "2.18.0",
version: "3",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@2.18.0", "--acp"],
command: ["npx", "-y", "cline@3", "--acp"],
},
{
id: "codebuddy-code",

View File

@@ -20,11 +20,14 @@ export function useContainerWidth(): {
/**
* Tracks only whether a container is narrower than a threshold.
*/
export function useContainerWidthBelow(threshold: number): {
export function useContainerWidthBelow(
threshold: number,
options?: { initialIsBelow?: boolean },
): {
onLayout: (e: LayoutChangeEvent) => void;
isBelow: boolean;
} {
const [isBelow, setIsBelow] = useState(true);
const [isBelow, setIsBelow] = useState(options?.initialIsBelow ?? true);
return {
onLayout: useCallback(
(e: LayoutChangeEvent) => {

View File

@@ -22,7 +22,7 @@ import {
useWorkspaceAttachments,
useWorkspaceAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
@@ -36,6 +36,7 @@ import {
} from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
@@ -1317,7 +1318,11 @@ function ActiveAgentComposer({
onMessageSent: () => void;
}) {
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const paneContext = usePaneContext();
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
const { archiveAgent } = useArchiveAgent();
@@ -1355,14 +1360,14 @@ function ActiveAgentComposer({
};
openFileExplorerForCheckout({
checkout,
isCompact,
isCompact: isCompactFormFactor,
});
setExplorerTabForCheckout({
...checkout,
tab: "changes",
});
},
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
);
const handleClientSlashCommand = useCallback(
@@ -1414,14 +1419,19 @@ function ActiveAgentComposer({
const composerFooter = useMemo(
() =>
isCompact ? (
<AgentModeControl serverId={serverId} agentId={agentId} placement="footer" />
isCompactComposerLayout ? (
<AgentModeControl
serverId={serverId}
agentId={agentId}
placement="footer"
isCompactLayout={isCompactComposerLayout}
/>
) : undefined,
[isCompact, serverId, agentId],
[isCompactComposerLayout, serverId, agentId],
);
return (
<ReanimatedAnimated.View style={inputAreaStyle}>
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
@@ -1449,6 +1459,7 @@ function ActiveAgentComposer({
onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
);

View File

@@ -1,5 +1,6 @@
import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import {
@@ -208,7 +209,6 @@ export interface WorkspaceTabSnapshot {
}
const DEFAULT_PANE_ID = "main";
const MIN_SPLIT_SIZE = 0.1;
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {

View File

@@ -0,0 +1 @@
export const MIN_SPLIT_SIZE = 0.1;

View File

@@ -1,6 +1,6 @@
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
export function derivePendingPermissionKey(
agentId: string,
@@ -26,11 +26,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
? new Date(snapshot.attentionTimestamp)
: null;
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
const parentAgentLabel = snapshot.labels?.[PARENT_AGENT_ID_LABEL];
const parentAgentId =
typeof parentAgentLabel === "string" && parentAgentLabel.trim().length > 0
? parentAgentLabel.trim()
: null;
const parentAgentId = getParentAgentIdFromLabels(snapshot.labels);
return {
serverId,

View File

@@ -6,9 +6,11 @@ import { resolveWorkspaceScriptLink } from "./workspace-script-links";
const runningService: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "web.feature.paseo.localhost",
hostname: "web--feature--paseo.localhost",
port: 3000,
proxyUrl: "http://web.feature.paseo.localhost:6767",
localProxyUrl: "http://web--feature--paseo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature--paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
@@ -27,8 +29,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect(
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
).toEqual({
openUrl: "http://web.feature.paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767",
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
@@ -36,8 +38,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect(
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
).toEqual({
openUrl: "http://web.feature.paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767",
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
@@ -59,7 +61,114 @@ describe("resolveWorkspaceScriptLink", () => {
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
).toEqual({
openUrl: null,
labelUrl: "http://web.feature.paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("opens the public URL over relay when one is provided", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
});
});
it("uses local URL for direct loopback even when public URL exists", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "directTcp", endpoint: "127.0.0.1:6767", display: "localhost" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("uses local URL for direct socket and pipe even when public URL exists", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "directPipe", endpoint: "paseo", display: "pipe" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("uses public URL for direct remote TCP when split URLs exist", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: {
type: "directTcp",
endpoint: "mac-mini.tail123.ts.net:6767",
display: "remote",
},
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
});
});
it("keeps old daemon local-only proxyUrl payloads working", () => {
const {
localProxyUrl: _localProxyUrl,
publicProxyUrl: _publicProxyUrl,
...oldPayload
} = runningService;
expect(
resolveWorkspaceScriptLink({
script: oldPayload,
activeConnection: { type: "directTcp", endpoint: "localhost:6767", display: "localhost" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("keeps old daemon public proxyUrl payloads working over relay", () => {
const {
localProxyUrl: _localProxyUrl,
publicProxyUrl: _publicProxyUrl,
...oldPayload
} = {
...runningService,
proxyUrl: "https://web--feature--paseo.services.example.com",
};
expect(
resolveWorkspaceScriptLink({
script: oldPayload,
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
});
});
});

View File

@@ -14,6 +14,19 @@ function isLoopbackHost(host: string): boolean {
);
}
function isLocalOnlyUrl(url: string | null | undefined): boolean {
if (!url) {
return true;
}
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
return isLoopbackHost(hostname) || hostname.endsWith(".localhost");
} catch {
return true;
}
}
function buildDirectServiceUrl(endpoint: string, port: number): string | null {
try {
const { host, isIpv6 } = parseHostPort(endpoint);
@@ -37,21 +50,33 @@ export function resolveWorkspaceScriptLink(input: {
return { openUrl: null, labelUrl: script.proxyUrl };
}
const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
const publicProxyUrl =
script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
if (activeConnection.type === "relay") {
return { openUrl: null, labelUrl: script.proxyUrl };
return {
openUrl: publicProxyUrl,
labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl,
};
}
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
}
try {
const { host } = parseHostPort(activeConnection.endpoint);
if (isLoopbackHost(host)) {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
}
} catch {
return { openUrl: null, labelUrl: script.proxyUrl };
return { openUrl: null, labelUrl: preferredProxyUrl };
}
if (publicProxyUrl) {
return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl };
}
if (script.port === null) {
@@ -61,6 +86,6 @@ export function resolveWorkspaceScriptLink(input: {
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
return {
openUrl: directUrl,
labelUrl: directUrl ?? script.proxyUrl,
labelUrl: directUrl ?? preferredProxyUrl,
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.88",
"version": "0.1.89",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -25,9 +25,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.88",
"@getpaseo/protocol": "0.1.88",
"@getpaseo/server": "0.1.88",
"@getpaseo/client": "0.1.89",
"@getpaseo/protocol": "0.1.89",
"@getpaseo/server": "0.1.89",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.88",
"version": "0.1.89",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -33,8 +33,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.88",
"@getpaseo/relay": "0.1.88",
"@getpaseo/protocol": "0.1.89",
"@getpaseo/relay": "0.1.89",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.88",
"version": "0.1.89",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -10,7 +10,17 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { app, BrowserWindow, Menu, ipcMain, nativeImage, net, protocol, session } from "electron";
import {
app,
BrowserWindow,
Menu,
ipcMain,
nativeImage,
net,
protocol,
screen,
session,
} from "electron";
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
import { closeAllTransportSessions } from "./daemon/local-transport.js";
@@ -19,7 +29,9 @@ import {
getMainWindowChromeOptions,
getWindowBackgroundColor,
resolveSystemWindowTheme,
resolveWindowBounds,
setupWindowResizeEvents,
setupWindowStatePersistence,
setupDefaultContextMenu,
setupDragDropPrevention,
buildStandardContextMenuItems,
@@ -41,6 +53,7 @@ import {
} from "./features/browser-webviews.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
import {
isDesktopManagedDaemonRunningSync,
stopDesktopDaemonViaCli,
@@ -372,15 +385,28 @@ function applyAppIcon(): void {
app.dock?.setIcon(icon);
}
// Work areas with the primary display first, so window-state clamping treats
// it as the fallback. getAllDisplays() order is not guaranteed to lead with it.
function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
const primary = screen.getPrimaryDisplay();
const others = screen.getAllDisplays().filter((display) => display.id !== primary.id);
return [primary, ...others].map((display) => display.workArea);
}
async function createMainWindow(): Promise<void> {
const iconPath = getWindowIconPath();
const systemTheme = resolveSystemWindowTheme();
const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") });
const savedWindowState = await windowStateStore.load();
const restoredWindowState = savedWindowState
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
: null;
const title = devWorktreeName ? `${APP_NAME} (${devWorktreeName})` : APP_NAME;
const mainWindow = new BrowserWindow({
title,
width: 1200,
height: 800,
...resolveWindowBounds(restoredWindowState),
show: false,
backgroundColor: getWindowBackgroundColor(systemTheme),
...(iconPath ? { icon: iconPath } : {}),
@@ -400,8 +426,13 @@ async function createMainWindow(): Promise<void> {
app.dock?.setBadge(devWorktreeName);
}
if (restoredWindowState?.isMaximized) {
mainWindow.maximize();
}
setupDarwinCompositorWatchdog(mainWindow);
setupWindowResizeEvents(mainWindow);
setupWindowStatePersistence(mainWindow, windowStateStore);
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {

View File

@@ -0,0 +1,205 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
MIN_WINDOW_HEIGHT,
MIN_WINDOW_WIDTH,
type WindowState,
type WorkArea,
clampWindowStateToWorkAreas,
createWindowStateStore,
} from "./window-state";
async function createTempUserDataDir(): Promise<string> {
return await mkdtemp(path.join(os.tmpdir(), "paseo-window-state-"));
}
function stateFilePath(userDataPath: string): string {
return path.join(userDataPath, "window-state.json");
}
const PRIMARY: WorkArea = { x: 0, y: 0, width: 1920, height: 1080 };
describe("window-state store", () => {
const directories = new Set<string>();
afterEach(async () => {
await Promise.all(
[...directories].map(async (directory) => {
await rm(directory, { recursive: true, force: true });
}),
);
directories.clear();
});
it("returns null when no state has been persisted yet", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
expect(await store.load()).toBeNull();
});
it("round-trips a saved state through disk", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
const state: WindowState = { x: 100, y: 200, width: 1000, height: 700, isMaximized: false };
await store.save(state);
expect(await store.load()).toEqual(state);
});
it("persists the maximized flag", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
await store.save({ x: 0, y: 0, width: 1280, height: 800, isMaximized: true });
expect((await store.load())?.isMaximized).toBe(true);
});
it("leaves no temp files behind after an async save", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
await store.save({ x: 10, y: 10, width: 800, height: 600, isMaximized: false });
expect(await readdir(userDataPath)).toEqual(["window-state.json"]);
});
it("writes atomically and synchronously via saveSync", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
store.saveSync({ x: 5, y: 6, width: 900, height: 650, isMaximized: false });
const persisted = JSON.parse(await readFile(stateFilePath(userDataPath), "utf8")) as {
version: number;
state: WindowState;
};
expect(persisted.state).toEqual({ x: 5, y: 6, width: 900, height: 650, isMaximized: false });
expect(await readdir(userDataPath)).toEqual(["window-state.json"]);
});
it("returns null for corrupted JSON instead of throwing", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(stateFilePath(userDataPath), "{ not valid json");
const store = createWindowStateStore({ userDataPath });
expect(await store.load()).toBeNull();
});
it("returns null when persisted state lacks usable dimensions", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(
stateFilePath(userDataPath),
JSON.stringify({ version: 1, state: { isMaximized: true } }),
);
const store = createWindowStateStore({ userDataPath });
expect(await store.load()).toBeNull();
});
it("clamps persisted dimensions up to the minimum size", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(
stateFilePath(userDataPath),
JSON.stringify({ version: 1, state: { width: 100, height: 50, isMaximized: false } }),
);
const store = createWindowStateStore({ userDataPath });
const loaded = await store.load();
expect(loaded?.width).toBe(MIN_WINDOW_WIDTH);
expect(loaded?.height).toBe(MIN_WINDOW_HEIGHT);
});
it("drops non-finite coordinates while keeping valid dimensions", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(
stateFilePath(userDataPath),
JSON.stringify({
version: 1,
state: { x: "nope", y: null, width: 1000, height: 700, isMaximized: false },
}),
);
const store = createWindowStateStore({ userDataPath });
const loaded = await store.load();
expect(loaded).toEqual({ width: 1000, height: 700, isMaximized: false });
});
});
describe("clampWindowStateToWorkAreas", () => {
it("keeps a state fully inside the primary display unchanged", () => {
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: false };
expect(clampWindowStateToWorkAreas(state, [PRIMARY])).toEqual(state);
});
it("keeps valid negative coordinates from a left-side secondary monitor", () => {
const left: WorkArea = { x: -1920, y: 0, width: 1920, height: 1080 };
const state: WindowState = { x: -1800, y: 80, width: 1000, height: 700, isMaximized: false };
expect(clampWindowStateToWorkAreas(state, [left, PRIMARY])).toEqual(state);
});
it("drops x/y when the window does not meaningfully intersect any display", () => {
const state: WindowState = { x: 5000, y: 5000, width: 1000, height: 700, isMaximized: false };
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
expect(clamped.x).toBeUndefined();
expect(clamped.y).toBeUndefined();
expect(clamped.width).toBe(1000);
expect(clamped.height).toBe(700);
});
it("shrinks an oversized window to the target work area", () => {
const state: WindowState = { x: 0, y: 0, width: 5000, height: 4000, isMaximized: false };
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
expect(clamped.width).toBe(PRIMARY.width);
expect(clamped.height).toBe(PRIMARY.height);
});
it("repositions an oversized edge window so it stays fully on-screen after shrinking", () => {
// Saved near the right edge and larger than the display: a naive clamp would
// keep x=1820 and shrink to 1920 wide, leaving the window mostly off-screen.
const state: WindowState = { x: 1820, y: 0, width: 3000, height: 2000, isMaximized: false };
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
expect(clamped.width).toBe(PRIMARY.width);
expect(clamped.height).toBe(PRIMARY.height);
expect(clamped.x).toBe(0);
expect(clamped.y).toBe(0);
});
it("drops position when there are no known displays", () => {
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: false };
const clamped = clampWindowStateToWorkAreas(state, []);
expect(clamped.x).toBeUndefined();
expect(clamped.y).toBeUndefined();
expect(clamped.width).toBe(1000);
expect(clamped.height).toBe(700);
});
it("preserves the maximized flag through clamping", () => {
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: true };
expect(clampWindowStateToWorkAreas(state, [PRIMARY]).isMaximized).toBe(true);
});
});

View File

@@ -0,0 +1,230 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
export const MIN_WINDOW_WIDTH = 400;
export const MIN_WINDOW_HEIGHT = 300;
// Smallest slice of the window that must remain on a display for the saved
// position to count as "still reachable" after the monitor layout changes.
const MIN_VISIBLE_WIDTH = 100;
const MIN_VISIBLE_HEIGHT = 80;
export interface WindowState {
x?: number;
y?: number;
width: number;
height: number;
isMaximized: boolean;
}
/** A display's usable area (excludes the menu bar / taskbar), in DIP coordinates. */
export interface WorkArea {
x: number;
y: number;
width: number;
height: number;
}
interface PersistedWindowStateDocument {
version: 1;
state: WindowState;
}
export interface WindowStateStore {
/** Returns the persisted state, or null when nothing usable is stored. */
load(): Promise<WindowState | null>;
/** Persists the state atomically off the main thread (serialized writes). */
save(state: WindowState): Promise<void>;
/** Persists the state synchronously — used as the final writer on close/quit. */
saveSync(state: WindowState): void;
}
const WINDOW_STATE_FILENAME = "window-state.json";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error;
}
function coerceFiniteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : null;
}
function coerceDimension(value: unknown, minimum: number): number | null {
const rounded = coerceFiniteNumber(value);
if (rounded === null) {
return null;
}
return Math.max(minimum, rounded);
}
export function coerceWindowState(input: unknown): WindowState | null {
if (!isRecord(input)) {
return null;
}
const width = coerceDimension(input.width, MIN_WINDOW_WIDTH);
const height = coerceDimension(input.height, MIN_WINDOW_HEIGHT);
if (width === null || height === null) {
return null;
}
const state: WindowState = { width, height, isMaximized: input.isMaximized === true };
const x = coerceFiniteNumber(input.x);
const y = coerceFiniteNumber(input.y);
// Only trust a position when both coordinates are present; a half-known
// position is worse than letting the OS place the window.
if (x !== null && y !== null) {
state.x = x;
state.y = y;
}
return state;
}
function serializeDocument(state: WindowState): string {
const document: PersistedWindowStateDocument = { version: 1, state };
return `${JSON.stringify(document, null, 2)}\n`;
}
/**
* Adjust a saved window state to the current display layout so the window never
* opens off-screen. Drops the saved position when it would not be reachable on
* any connected display, and shrinks oversized windows to the target work area.
* Pure: the caller supplies the display work areas (from Electron's `screen`).
*/
export function clampWindowStateToWorkAreas(
state: WindowState,
workAreas: WorkArea[],
): WindowState {
const primary = workAreas[0];
if (!primary) {
// No display info — keep the size, let the OS place the window.
return { width: state.width, height: state.height, isMaximized: state.isMaximized };
}
let target: WorkArea = primary;
const { x, y } = state;
let positioned = false;
if (x !== undefined && y !== undefined) {
const requiredWidth = Math.min(MIN_VISIBLE_WIDTH, state.width);
const requiredHeight = Math.min(MIN_VISIBLE_HEIGHT, state.height);
let bestOverlap = 0;
for (const workArea of workAreas) {
const overlapWidth = Math.max(
0,
Math.min(x + state.width, workArea.x + workArea.width) - Math.max(x, workArea.x),
);
const overlapHeight = Math.max(
0,
Math.min(y + state.height, workArea.y + workArea.height) - Math.max(y, workArea.y),
);
const overlap = overlapWidth * overlapHeight;
const isVisibleEnough = overlapWidth >= requiredWidth && overlapHeight >= requiredHeight;
if (isVisibleEnough && overlap > bestOverlap) {
bestOverlap = overlap;
target = workArea;
positioned = true;
}
}
}
const width = Math.min(Math.max(state.width, MIN_WINDOW_WIDTH), target.width);
const height = Math.min(Math.max(state.height, MIN_WINDOW_HEIGHT), target.height);
if (positioned && x !== undefined && y !== undefined) {
// Keep the window inside the chosen display after the size clamp so an
// oversized saved state cannot end up mostly off-screen.
const clampedX = Math.min(Math.max(x, target.x), target.x + target.width - width);
const clampedY = Math.min(Math.max(y, target.y), target.y + target.height - height);
return { x: clampedX, y: clampedY, width, height, isMaximized: state.isMaximized };
}
return { width, height, isMaximized: state.isMaximized };
}
export function createWindowStateStore({
userDataPath,
}: {
userDataPath: string;
}): WindowStateStore {
const filePath = path.join(userDataPath, WINDOW_STATE_FILENAME);
let persistQueue: Promise<void> = Promise.resolve();
// Once the synchronous final write lands (on close/quit), pending async
// writes must not clobber it with an older snapshot.
let finalized = false;
function tempFilePath(): string {
return `${filePath}.tmp.${process.pid}.${randomUUID()}`;
}
return {
async load(): Promise<WindowState | null> {
let raw: string;
try {
raw = await readFile(filePath, "utf8");
} catch (error) {
// No file yet (first launch) is expected; surface anything else.
if (isNodeError(error) && error.code === "ENOENT") {
return null;
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
// A corrupted file shouldn't block launch; non-critical state falls back.
if (error instanceof SyntaxError) {
return null;
}
throw error;
}
if (!isRecord(parsed)) {
return null;
}
return coerceWindowState(parsed.state);
},
async save(state: WindowState): Promise<void> {
const contents = serializeDocument(state);
async function write(): Promise<void> {
if (finalized) {
return;
}
await mkdir(userDataPath, { recursive: true });
const tempPath = tempFilePath();
await writeFile(tempPath, contents, "utf8");
if (finalized) {
// A synchronous final write (saveSync) landed while this one was in
// flight. Keep it as the last writer instead of overwriting it, and
// discard our now-stale temp file so it can't accumulate in userData.
await unlink(tempPath).catch(() => undefined);
return;
}
await rename(tempPath, filePath);
}
const queued = persistQueue.then(write, write);
persistQueue = queued.catch(() => undefined);
await queued;
},
saveSync(state: WindowState): void {
finalized = true;
const contents = serializeDocument(state);
mkdirSync(userDataPath, { recursive: true });
const tempPath = tempFilePath();
writeFileSync(tempPath, contents, "utf8");
renameSync(tempPath, filePath);
},
};
}

View File

@@ -3,12 +3,15 @@ import { describe, expect, it, vi } from "vitest";
import {
applyWindowControlsOverlayUpdate,
createWindowControlsOverlayState,
DEFAULT_WINDOW_HEIGHT,
DEFAULT_WINDOW_WIDTH,
getMainWindowChromeOptions,
getTitleBarOverlayOptions,
readBadgeCount,
readWindowControlsOverlayUpdate,
readWindowTheme,
resolveRuntimeTitleBarOverlayOptions,
resolveWindowBounds,
} from "./window-manager";
describe("window-manager", () => {
@@ -186,4 +189,26 @@ describe("window-manager", () => {
});
});
});
describe("resolveWindowBounds", () => {
it("falls back to the default size when no state is saved", () => {
expect(resolveWindowBounds(null)).toEqual({
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
});
});
it("restores the full size and position", () => {
expect(
resolveWindowBounds({ x: 120, y: 80, width: 1024, height: 720, isMaximized: false }),
).toEqual({ width: 1024, height: 720, x: 120, y: 80 });
});
it("omits the position when only the size was persisted", () => {
expect(resolveWindowBounds({ width: 1024, height: 720, isMaximized: true })).toEqual({
width: 1024,
height: 720,
});
});
});
});

View File

@@ -10,6 +10,10 @@ import {
shell,
} from "electron";
import type { WindowState, WindowStateStore } from "../settings/window-state.js";
const WINDOW_STATE_SAVE_DEBOUNCE_MS = 400;
export function readBadgeCount(input: unknown): number {
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
return 0;
@@ -87,6 +91,26 @@ export function getMainWindowChromeOptions(input: {
};
}
export const DEFAULT_WINDOW_WIDTH = 1200;
export const DEFAULT_WINDOW_HEIGHT = 800;
/**
* Window size/position options for the BrowserWindow constructor, derived from
* a restored state when available. Falls back to the default size, and only
* sets x/y when a full position was persisted (a partial state lets the OS
* place the window).
*/
export function resolveWindowBounds(
state: WindowState | null,
): Pick<Electron.BrowserWindowConstructorOptions, "width" | "height" | "x" | "y"> {
const width = state?.width ?? DEFAULT_WINDOW_WIDTH;
const height = state?.height ?? DEFAULT_WINDOW_HEIGHT;
if (state?.x !== undefined && state?.y !== undefined) {
return { width, height, x: state.x, y: state.y };
}
return { width, height };
}
function readFiniteOverlayHeight(input: unknown): number | null {
if (typeof input !== "number" || !Number.isFinite(input)) {
return null;
@@ -229,6 +253,96 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
});
}
/**
* Persist the window's size/position/maximized state so it can be restored on
* the next launch. Debounces disk writes on resize/move, writes immediately on
* maximize/unmaximize, and flushes synchronously on close so the final state
* survives quit/reboot. The latest geometry is captured into memory on every
* event so a queued async write can never overwrite the close-time snapshot.
*/
export function setupWindowStatePersistence(win: BrowserWindow, store: WindowStateStore): void {
let latestState: WindowState | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let flushed = false;
function clearTimer(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer);
saveTimer = null;
}
}
function captureState(): void {
// Skip transient geometry: maximized/fullscreen bounds aren't the size we
// want to restore to, and a minimized window reports misleading bounds.
if (win.isMinimized() || win.isFullScreen()) {
return;
}
const bounds = win.getNormalBounds();
latestState = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: win.isMaximized(),
};
}
function persist(): void {
if (latestState) {
void store.save(latestState).catch((error) => {
console.warn("[window-manager] Failed to persist window state", error);
});
}
}
function scheduleSave(): void {
captureState();
clearTimer();
saveTimer = setTimeout(() => {
saveTimer = null;
persist();
}, WINDOW_STATE_SAVE_DEBOUNCE_MS);
}
function saveNow(): void {
captureState();
clearTimer();
persist();
}
// Final synchronous flush. Runs on window close AND on app quit: the app's
// before-quit handler calls app.exit(0), which bypasses the window close
// event (see daemon/quit-lifecycle.ts), so close alone would miss Cmd+Q.
function flushFinal(): void {
if (flushed) {
return;
}
flushed = true;
clearTimer();
captureState();
if (latestState) {
try {
store.saveSync(latestState);
} catch (error) {
console.warn("[window-manager] Failed to persist window state on exit", error);
}
}
}
win.on("resize", scheduleSave);
win.on("move", scheduleSave);
win.on("maximize", saveNow);
win.on("unmaximize", saveNow);
win.on("close", flushFinal);
app.on("before-quit", flushFinal);
win.on("closed", () => {
clearTimer();
app.removeListener("before-quit", flushFinal);
});
}
export function buildStandardContextMenuItems(
contents: WebContents,
params: Electron.ContextMenuParams,

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.88",
"version": "0.1.89",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.88",
"version": "0.1.89",
"files": [
"dist",
"!dist/**/*.map"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.88",
"version": "0.1.89",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",

View File

@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import {
getParentAgentIdFromLabels,
isDelegatedAgent,
PARENT_AGENT_ID_LABEL,
} from "./agent-labels.js";
describe("agent label policy", () => {
test("treats a non-empty parent agent label as delegation", () => {
const labels = { [PARENT_AGENT_ID_LABEL]: " parent-agent \n" };
expect(getParentAgentIdFromLabels(labels)).toBe("parent-agent");
expect(isDelegatedAgent({ labels })).toBe(true);
});
test("ignores missing, empty, and non-string parent agent labels", () => {
expect(isDelegatedAgent({ labels: {} })).toBe(false);
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false);
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false);
});
});

View File

@@ -1 +1,16 @@
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
export interface AgentLabelSource {
labels?: Record<string, unknown> | null;
}
export function getParentAgentIdFromLabels(labels: Record<string, unknown> | null | undefined) {
const parentAgentId = labels?.[PARENT_AGENT_ID_LABEL];
return typeof parentAgentId === "string" && parentAgentId.trim().length > 0
? parentAgentId.trim()
: null;
}
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
return getParentAgentIdFromLabels(agent.labels) !== null;
}

View File

@@ -2325,6 +2325,8 @@ export const WorkspaceScriptPayloadSchema = z.object({
type: z.enum(["script", "service"]).optional().default("service"),
hostname: z.string(),
port: z.number().int().positive().nullable(),
localProxyUrl: z.string().nullable().optional(),
publicProxyUrl: z.string().nullable().optional(),
proxyUrl: z.string().nullable().optional().default(null),
lifecycle: WorkspaceScriptLifecycleSchema,
health: WorkspaceScriptHealthSchema.nullable(),

View File

@@ -5,6 +5,7 @@ import {
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WorkspaceDescriptorPayloadSchema,
WorkspaceScriptPayloadSchema,
} from "./messages.js";
describe("workspace message schemas", () => {
@@ -485,6 +486,53 @@ describe("workspace message schemas", () => {
});
});
test("parses workspace service payloads from old daemons without split proxy URLs", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "web",
type: "service",
hostname: "web--repo.localhost",
port: 3000,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "running",
health: "healthy",
});
expect(parsed.localProxyUrl).toBeUndefined();
expect(parsed.publicProxyUrl).toBeUndefined();
expect(parsed.proxyUrl).toBe("http://web--repo.localhost:6767");
});
test("parses workspace service payloads with split local and public proxy URLs", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "web",
type: "service",
hostname: "web--repo.localhost",
port: 3000,
localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: "https://web--repo.services.example.com",
proxyUrl: "https://web--repo.services.example.com",
lifecycle: "running",
health: "healthy",
});
expect(parsed.localProxyUrl).toBe("http://web--repo.localhost:6767");
expect(parsed.publicProxyUrl).toBe("https://web--repo.services.example.com");
expect(parsed.proxyUrl).toBe("https://web--repo.services.example.com");
});
test("defaults omitted workspace script proxyUrl to null", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "typecheck",
type: "script",
hostname: "typecheck",
port: null,
lifecycle: "stopped",
health: null,
});
expect(parsed.proxyUrl).toBeNull();
});
test("parses workspace_setup_progress payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.88",
"version": "0.1.89",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.88",
"version": "0.1.89",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -57,10 +57,10 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.88",
"@getpaseo/highlight": "0.1.88",
"@getpaseo/protocol": "0.1.88",
"@getpaseo/relay": "0.1.88",
"@getpaseo/client": "0.1.89",
"@getpaseo/highlight": "0.1.89",
"@getpaseo/protocol": "0.1.89",
"@getpaseo/relay": "0.1.89",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -3892,6 +3892,39 @@ test("onAgentAttention is not called for internal agents", async () => {
expect(attentionCalls).toHaveLength(0);
});
test("onAgentAttention is not called for delegated child agents", async () => {
const childAgentId = "00000000-0000-4000-8000-000000000112";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const attentionCalls: string[] = [];
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => childAgentId,
onAgentAttention: ({ agentId }) => {
attentionCalls.push(agentId);
},
});
const agent = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
title: "Delegated Child Agent",
},
undefined,
{ labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" } },
);
await manager.runAgent(agent.id, "hello");
expect(attentionCalls).toEqual([]);
});
test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
const storagePath = join(workdir, "agents");

View File

@@ -5,7 +5,7 @@ import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "@getpaseo/protocol/agent-lifecycle";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { Logger } from "pino";
import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
@@ -3368,6 +3368,10 @@ export class AgentManager {
agent: ManagedAgent,
reason: "finished" | "error" | "permission",
): void {
if (isDelegatedAgent(agent)) {
return;
}
this.onAgentAttention?.({
agentId: agent.id,
provider: agent.provider,

View File

@@ -19,6 +19,7 @@ import type { AgentStorage } from "./agent-storage.js";
interface CreateAgentLifecycleDispatchDependencies {
paseoHome: string;
worktreesRoot?: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
github: GitHubService;
@@ -106,6 +107,7 @@ export class CreateAgentLifecycleDispatch {
firstAgentContext,
runSetup: false,
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
} as const;
switch (target.mode) {
@@ -191,6 +193,7 @@ export class CreateAgentLifecycleDispatch {
}): Promise<void> {
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
});
if (!ownership.allowed) {
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
@@ -199,6 +202,7 @@ export class CreateAgentLifecycleDispatch {
await archivePaseoWorktree(
{
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
github: this.dependencies.github,
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
@@ -215,6 +219,7 @@ export class CreateAgentLifecycleDispatch {
targetPath: options.worktreePath,
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: this.dependencies.worktreesRoot,
requestId: randomUUID(),
},
);

View File

@@ -46,6 +46,7 @@ interface CreateAgentCommandDependencies {
agentStorage: AgentStorage;
logger: Logger;
paseoHome?: string;
worktreesRoot?: string;
workspaceGitService?: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
@@ -93,6 +94,7 @@ export interface CreateAgentFromMcpInput {
mode?: string;
background: boolean;
notifyOnFinish: boolean;
detached?: boolean;
callerAgentId?: string;
callerContext?: {
lockedCwd?: string;
@@ -256,11 +258,12 @@ async function resolveMcpCreateAgent(
parent: parentAgent,
});
const labels = mergeLabels(
input.callerAgentId,
input.callerContext?.childAgentDefaultLabels,
input.labels,
);
const labels = mergeLabels({
callerAgentId: input.callerAgentId,
detached: input.detached ?? false,
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
labels: input.labels,
});
const trimmedPrompt = input.initialPrompt.trim();
return {
@@ -417,6 +420,7 @@ async function resolveMcpCwd(params: {
...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}),
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
},
createPaseoWorktree: dependencies.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
@@ -469,15 +473,21 @@ async function createMcpWorktree(
}
}
function mergeLabels(
callerAgentId: string | undefined,
childAgentDefaultLabels: Record<string, string> | undefined,
labels: Record<string, string> | undefined,
): Record<string, string> | undefined {
function mergeLabels(params: {
callerAgentId: string | undefined;
detached: boolean;
childAgentDefaultLabels: Record<string, string> | undefined;
labels: Record<string, string> | undefined;
}): Record<string, string> | undefined {
const mergedLabels = {
...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}),
...childAgentDefaultLabels,
...labels,
...(!params.detached && params.callerAgentId
? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId }
: {}),
...params.childAgentDefaultLabels,
...params.labels,
};
if (params.detached) {
delete mergedLabels[PARENT_AGENT_ID_LABEL];
}
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
}

View File

@@ -166,7 +166,7 @@ async function createChildAgent(args?: Partial<StructuredContent>): Promise<stri
title: "Parity child",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
background: true,
notifyOnFinish: false,
...args,
});
return str(payload.agentId);
@@ -286,6 +286,17 @@ describe("Suite A: Core Fixes", () => {
}
});
test("create_agent with detached true omits the parent agent label", async () => {
let agentId: string | null = null;
try {
agentId = await createChildAgent({ detached: true });
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(snapshot?.labels?.[PARENT_AGENT_ID_LABEL]).toBeUndefined();
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => {
let agentId: string | null = null;
try {
@@ -565,7 +576,7 @@ describe("Suite C: Schedule Tools", () => {
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
cron: "*/5 * * * *",
name: "Parity schedule list",
provider: "claude",
});
@@ -591,7 +602,7 @@ describe("Suite C: Schedule Tools", () => {
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
cron: "*/5 * * * *",
name: "Parity provider schedule",
provider: "codex/gpt-5.4",
});
@@ -613,7 +624,7 @@ describe("Suite C: Schedule Tools", () => {
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
cron: "*/5 * * * *",
name: "Parity inspect schedule",
provider: "claude",
});
@@ -638,7 +649,7 @@ describe("Suite C: Schedule Tools", () => {
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
cron: "*/5 * * * *",
name: "Parity pause schedule",
provider: "claude",
});
@@ -665,7 +676,7 @@ describe("Suite C: Schedule Tools", () => {
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
cron: "*/5 * * * *",
name: "Parity delete schedule",
provider: "claude",
});
@@ -682,14 +693,13 @@ describe("Suite C: Schedule Tools", () => {
}
});
test("create_schedule target self with callerAgentId", async () => {
test("create_heartbeat targets the scoped agent", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(agentScopedClient, "create_schedule", {
const created = await callToolStructured(agentScopedClient, "create_heartbeat", {
prompt: "say hello",
every: "5m",
name: "Parity self schedule",
target: "self",
cron: "*/5 * * * *",
name: "Parity heartbeat",
});
scheduleId = str(created.id);
expect(created.target).toMatchObject({
@@ -706,7 +716,7 @@ describe("Suite C: Schedule Tools", () => {
try {
const created = await callToolStructured(agentScopedClient, "create_schedule", {
prompt: "say hello",
every: "5m",
cron: "*/5 * * * *",
provider: "codex/gpt-5.4",
});
scheduleId = str(created.id);
@@ -722,16 +732,15 @@ describe("Suite C: Schedule Tools", () => {
}
});
test("create_schedule target self without callerAgentId throws", async () => {
test("create_heartbeat without callerAgentId throws", async () => {
await expectToolError(
topLevelClient,
"create_schedule",
"create_heartbeat",
{
prompt: "say hello",
every: "5m",
target: "self",
cron: "*/5 * * * *",
},
/requires a caller agent/i,
/requires an agent-scoped session/i,
);
});
});

View File

@@ -1692,6 +1692,141 @@ describe("create_agent MCP tool", () => {
await rm(baseDir, { recursive: true, force: true });
});
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: false,
}),
).rejects.toThrow(/Unrecognized key/);
const parsed = await tool.inputSchema.safeParseAsync({
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
throw new Error("Expected caller create_agent input to parse");
}
expect(parsed.data).toMatchObject({
detached: false,
notifyOnFinish: true,
});
});
it("returns notify-on-finish guidance for caller-created agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
const childAgent = {
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent;
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
if (agentId === "parent-agent") return parentAgent;
if (agentId === "child-agent") return childAgent;
return null;
});
spies.agentManager.createAgent.mockResolvedValue(childAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
expect(response.structuredContent.guidance).toBe(
"You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
);
});
it("creates detached caller agents without a parent label", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
spies.agentManager.createAgent.mockResolvedValue({
id: "detached-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Detached" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
title: "Detached",
provider: "codex/gpt-5.4",
initialPrompt: "Take over",
detached: true,
labels: {
[PARENT_AGENT_ID_LABEL]: "spoofed-parent",
source: "handoff",
},
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: existingCwd,
}),
undefined,
{
labels: {
source: "handoff",
},
},
);
});
it("accepts provider features from caller agents and passes them through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
@@ -1721,7 +1856,6 @@ describe("create_agent MCP tool", () => {
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
settings: { features: { fast_mode: true } },
};
@@ -2139,7 +2273,7 @@ describe("update_agent MCP tool", () => {
describe("create_schedule MCP tool", () => {
const logger = createTestLogger();
it("requires provider for new-agent schedules", async () => {
it("requires provider for schedules", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const server = await createAgentMcpServer({
@@ -2154,7 +2288,7 @@ describe("create_schedule MCP tool", () => {
await expect(
tool.handler({
prompt: "say hello",
every: "5m",
cron: "*/5 * * * *",
name: "Default schedule",
}),
).rejects.toThrow("provider is required when target is new-agent");
@@ -2175,12 +2309,12 @@ describe("create_schedule MCP tool", () => {
await tool.handler({
prompt: "say hello",
every: "5m",
cron: "*/5 * * * *",
provider: "codex",
});
await tool.handler({
prompt: "say hello again",
every: "10m",
cron: "*/10 * * * *",
provider: "codex/gpt-5.4",
});
@@ -2239,8 +2373,7 @@ describe("create_schedule MCP tool", () => {
const response = await tool.handler({
prompt: "say hello",
every: "5m",
target: "new-agent",
cron: "*/5 * * * *",
provider: "opencode/openai/gpt-5.5",
});
@@ -2251,83 +2384,6 @@ describe("create_schedule MCP tool", () => {
expectOutputSchemaAccepts(tool, response.structuredContent);
});
it("accepts a blank cron field when every is provided", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
createStoredSchedule(scheduleInput),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await invokeToolWithParsedInput(tool, {
prompt: "say hello",
every: "10m",
cron: "",
provider: "codex",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
cadence: { type: "every", everyMs: 600000 },
}),
);
});
it.each([
{
label: "whitespace cron field",
input: { prompt: "say hello", every: "10m", cron: " ", provider: "codex" },
cadence: { type: "every", everyMs: 600000 },
},
{
label: "blank every field for cron cadence",
input: {
prompt: "say hello",
every: "",
cron: "*/10 * * * *",
provider: "codex",
},
cadence: { type: "cron", expression: "*/10 * * * *" },
},
{
label: "whitespace every field for cron cadence",
input: {
prompt: "say hello",
every: " ",
cron: "*/10 * * * *",
provider: "codex",
},
cadence: { type: "cron", expression: "*/10 * * * *" },
},
])("normalizes create_schedule blank cadence input for $label", async ({ input, cadence }) => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
createStoredSchedule(scheduleInput),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await invokeToolWithParsedInput(tool, input);
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
cadence,
}),
);
});
it("passes timezone through cron create_schedule input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
@@ -2360,7 +2416,7 @@ describe("create_schedule MCP tool", () => {
);
});
it("still rejects both real every and cron inputs", async () => {
it("rejects removed create_schedule every input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
@@ -2372,19 +2428,17 @@ describe("create_schedule MCP tool", () => {
});
const tool = registeredTool(server, "create_schedule");
await expect(
invokeToolWithParsedInput(tool, {
prompt: "say hello",
every: "10m",
cron: "*/10 * * * *",
provider: "codex",
}),
).rejects.toThrow("Specify exactly one of every or cron");
const parsed = await tool.inputSchema.safeParseAsync({
prompt: "say hello",
every: "10m",
provider: "codex",
});
expect(parsed.success).toBe(false);
expect(create).not.toHaveBeenCalled();
});
it("rejects create_schedule timezone without cron", async () => {
it("rejects create_schedule without cron", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
@@ -2397,13 +2451,11 @@ describe("create_schedule MCP tool", () => {
const tool = registeredTool(server, "create_schedule");
await expect(
invokeToolWithParsedInput(tool, {
tool.handler({
prompt: "say hello",
every: "10m",
timezone: "America/New_York",
provider: "codex",
}),
).rejects.toThrow("timezone can only be used with cron");
).rejects.toThrow(/cron/);
expect(create).not.toHaveBeenCalled();
});
@@ -2431,17 +2483,55 @@ describe("create_schedule MCP tool", () => {
expect(create).not.toHaveBeenCalled();
});
});
it.each([
{
label: "missing both cadence fields",
input: { prompt: "say hello", provider: "codex" },
},
{
label: "blank cadence fields",
input: { prompt: "say hello", every: " ", cron: "", provider: "codex" },
},
])("still rejects create_schedule when $label", async ({ input }) => {
describe("create_heartbeat MCP tool", () => {
const logger = createTestLogger();
it("creates a self-targeted cron heartbeat", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
provider: "codex",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: "build",
availableModes: [],
config: { title: "Parent agent" },
} as ManagedAgent);
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_heartbeat");
await invokeToolWithParsedInput(tool, {
prompt: "check status",
cron: "*/15 * * * *",
timezone: "America/New_York",
name: "status heartbeat",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "check status",
cadence: {
type: "cron",
expression: "*/15 * * * *",
timezone: "America/New_York",
},
target: { type: "agent", agentId: "parent-agent" },
name: "status heartbeat",
}),
);
});
it("requires an agent-scoped session", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
@@ -2451,11 +2541,14 @@ describe("create_schedule MCP tool", () => {
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
const tool = registeredTool(server, "create_heartbeat");
await expect(invokeToolWithParsedInput(tool, input)).rejects.toThrow(
"Specify exactly one of every or cron",
);
await expect(
tool.handler({
prompt: "check status",
cron: "*/15 * * * *",
}),
).rejects.toThrow("create_heartbeat requires an agent-scoped session");
expect(create).not.toHaveBeenCalled();
});
@@ -3131,7 +3224,7 @@ describe("speak MCP tool", () => {
});
const tool = registeredTool(server, "speak");
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
"No speak handler registered for caller agent",
"No speak handler registered for your session",
);
});

View File

@@ -99,6 +99,7 @@ export interface AgentMcpServerOptions {
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
paseoHome?: string;
worktreesRoot?: string;
/**
* ID of the agent that is connecting to this MCP server.
* Used for cwd/mode inheritance when agents spawn child agents.
@@ -514,6 +515,28 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
registerRawTool(name, relaxMcpToolOutputSchema(config), (async (args: never, extra: never) =>
addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler);
const buildCronScheduleCadence = (input: {
cron: string | undefined;
timezone?: string;
}): ScheduleCadence => {
const expression = input.cron?.trim() ?? "";
if (!expression) {
throw new Error("cron is required");
}
const timezone = normalizeScheduleTimeZoneArg(input.timezone);
return {
type: "cron",
expression,
...(timezone !== undefined ? { timezone } : {}),
};
};
const buildScheduleExpiry = (expiresIn: string | undefined): string | undefined => {
return expiresIn === undefined
? undefined
: new Date(Date.now() + parseDurationString(expiresIn)).toISOString();
};
const resolveCallerAgent = () => {
if (!callerAgentId) {
return null;
@@ -541,7 +564,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (opts?.required) {
throw new Error("cwd is required");
}
throw new Error("cwd is required when no caller agent is available");
throw new Error("cwd is required outside an agent-scoped session");
}
return expandUserPath(trimmedCwd);
@@ -699,7 +722,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cwd: z
.string()
.optional()
.describe("Optional working directory. Defaults to the caller agent working directory."),
.describe("Optional working directory. Defaults to your current working directory."),
title: z
.string()
.trim()
@@ -718,19 +741,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.trim()
.min(1, "initialPrompt is required")
.describe("Required first task to run immediately after creation."),
background: z
detached: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
"If true, the created agent stands on its own: it does not appear in your subagent track and is not archived with you.",
),
notifyOnFinish: z
.boolean()
.optional()
.default(false)
.default(true)
.describe(
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.",
"Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.",
),
};
@@ -787,7 +810,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.optional()
.default(false)
.describe(
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.",
"Agent-scoped only: get notified when the created agent finishes, errors, or needs permission.",
),
};
@@ -806,6 +829,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"Draft provider settings used to compute available features.",
),
};
type AgentToAgentCreateAgentArgs = z.infer<typeof agentToAgentCreateAgentArgsSchema>;
type TopLevelCreateAgentArgs = z.infer<typeof topLevelCreateAgentArgsSchema>;
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
@@ -832,7 +856,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
const handler = resolveSpeakHandler?.(callerAgentId) ?? null;
if (!handler) {
throw new Error(`No speak handler registered for caller agent '${callerAgentId}'`);
throw new Error(`No speak handler registered for your session '${callerAgentId}'`);
}
await handler({
text: args.text,
@@ -867,16 +891,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
availableModes: z.array(ProviderModeSchema),
lastMessage: z.string().nullable().optional(),
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
guidance: z.string().optional(),
},
},
async (args: unknown) => {
const { parsedArgs, worktree } = resolveCreateAgentToolArgs(args);
const { snapshot, background, initialPromptStarted } = await createAgentCommand(
const resolvedArgs = resolveCreateAgentToolArgs(args);
const { parsedArgs, worktree } = resolvedArgs;
let requestedBackground: boolean;
let notifyOnFinish: boolean;
let detached: boolean;
if (resolvedArgs.kind === "agent-scoped") {
requestedBackground = true;
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish;
detached = resolvedArgs.parsedArgs.detached;
} else {
requestedBackground = resolvedArgs.parsedArgs.background;
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false;
detached = false;
}
const {
snapshot,
background: createdInBackground,
initialPromptStarted,
} = await createAgentCommand(
{
agentManager,
agentStorage,
logger: childLogger,
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
workspaceGitService: options.workspaceGitService,
terminalManager,
providerSnapshotManager,
@@ -892,8 +935,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
features: parsedArgs.settings?.features,
labels: parsedArgs.labels,
mode: parsedArgs.settings?.modeId,
background: parsedArgs.background ?? false,
notifyOnFinish: parsedArgs.notifyOnFinish ?? false,
background: requestedBackground,
notifyOnFinish,
detached,
callerAgentId,
callerContext,
worktree,
@@ -901,7 +945,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
);
try {
if (!background && initialPromptStarted) {
if (!createdInBackground && initialPromptStarted) {
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
waitForActive: true,
});
@@ -930,8 +974,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
throw error;
}
// Return immediately if background=true
// Return immediately for async creation.
const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot;
const guidance =
callerAgentId && notifyOnFinish && initialPromptStarted
? "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives."
: undefined;
const response = {
content: [],
structuredContent: ensureValidJson({
@@ -943,26 +991,36 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
availableModes: currentSnapshot.availableModes,
lastMessage: null,
permission: null,
...(guidance ? { guidance } : {}),
}),
};
return response;
},
);
function resolveCreateAgentToolArgs(args: unknown): {
parsedArgs:
| z.infer<typeof agentToAgentCreateAgentArgsSchema>
| z.infer<typeof topLevelCreateAgentArgsSchema>;
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
} {
type ResolvedCreateAgentToolArgs =
| {
kind: "agent-scoped";
parsedArgs: AgentToAgentCreateAgentArgs;
worktree: undefined;
}
| {
kind: "top-level";
parsedArgs: TopLevelCreateAgentArgs;
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
};
function resolveCreateAgentToolArgs(args: unknown): ResolvedCreateAgentToolArgs {
if (callerAgentId) {
return {
kind: "agent-scoped",
parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args),
worktree: undefined,
};
}
const parsedArgs = topLevelCreateAgentArgsSchema.parse(args);
return {
kind: "top-level",
parsedArgs,
worktree: resolveTopLevelCreateAgentWorktree(parsedArgs),
};
@@ -1088,7 +1146,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.optional()
.default(false)
.describe(
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission.",
"Agent-scoped only: get notified when this run finishes, errors, or needs permission.",
),
},
outputSchema: {
@@ -1402,7 +1460,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cwd: z
.string()
.optional()
.describe("Optional working directory. Defaults to the caller agent cwd."),
.describe("Optional working directory. Defaults to your current working directory."),
all: z.boolean().optional().describe("List terminals across all working directories."),
},
outputSchema: {
@@ -1450,7 +1508,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cwd: z
.string()
.optional()
.describe("Optional working directory. Defaults to the caller agent cwd."),
.describe("Optional working directory. Defaults to your current working directory."),
name: z.string().optional().describe("Optional terminal name."),
},
outputSchema: TerminalSummarySchema.shape,
@@ -1591,22 +1649,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"create_schedule",
{
title: "Create schedule",
description: "Create a recurring schedule that runs on an agent or a new agent.",
description: "Create a recurring schedule that starts a new agent on a cron cadence.",
inputSchema: {
prompt: z.string().trim().min(1, "prompt is required"),
every: z.string().optional(),
cron: z.string().optional(),
cron: z.string().trim().min(1, "cron is required"),
timezone: z
.string()
.trim()
.min(1)
.optional()
.describe(
"IANA time zone for cron cadence; requires cron. For example: America/New_York.",
),
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
target: z.enum(["self", "new-agent"]).optional(),
provider: AgentProviderEnum.optional().describe(
provider: AgentProviderEnum.describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
),
cwd: z.string().optional(),
@@ -1615,70 +1669,71 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
outputSchema: ScheduleSummarySchema.shape,
},
async ({ prompt, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn }) => {
async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
const normalizedEvery = normalizeScheduleCadenceArg(every);
const normalizedCron = normalizeScheduleCadenceArg(cron);
const normalizedTimeZone = normalizeScheduleTimeZoneArg(timezone);
const cadenceCount =
Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined);
if (cadenceCount !== 1) {
throw new Error("Specify exactly one of every or cron");
}
if (normalizedTimeZone !== undefined && normalizedCron === undefined) {
throw new Error("timezone can only be used with cron");
}
const scheduleTarget =
target === "self"
? (() => {
const callerAgent = resolveCallerAgent();
if (!callerAgentId || !callerAgent) {
throw new Error("target=self requires a caller agent");
}
const trimmedCwd = cwd?.trim();
if (trimmedCwd && expandUserPath(trimmedCwd) !== callerAgent.cwd) {
throw new Error("cwd can only differ from the caller agent when target=new-agent");
}
if (provider !== undefined) {
const resolved = resolveScheduleProviderAndModel({
provider,
defaultProvider: callerAgent.provider,
});
if (
resolved.provider !== callerAgent.provider ||
(resolved.model !== undefined && resolved.model !== callerAgent.config.model)
) {
throw new Error(
"provider can only differ from the caller agent when target=new-agent",
);
}
}
return { type: "agent" as const, agentId: callerAgentId };
})()
: (() => {
return resolveNewAgentScheduleTarget({ provider, cwd });
})();
const expiresAt = buildScheduleExpiry(expiresIn);
const schedule = await scheduleService.create({
prompt: prompt.trim(),
cadence:
normalizedEvery !== undefined
? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) }
: {
type: "cron" as const,
expression: normalizedCron!,
...(normalizedTimeZone !== undefined ? { timezone: normalizedTimeZone } : {}),
},
target: scheduleTarget,
cadence: buildCronScheduleCadence({
cron,
...(timezone !== undefined ? { timezone } : {}),
}),
target: resolveNewAgentScheduleTarget({ provider, cwd }),
...(name?.trim() ? { name: name.trim() } : {}),
...(maxRuns === undefined ? {} : { maxRuns }),
...(expiresIn === undefined
? {}
: { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }),
...(expiresAt === undefined ? {} : { expiresAt }),
});
return {
content: [],
structuredContent: ensureValidJson(toScheduleSummary(schedule)),
};
},
);
registerTool(
"create_heartbeat",
{
title: "Create heartbeat",
description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.",
inputSchema: {
prompt: z.string().trim().min(1, "prompt is required"),
cron: z.string().trim().min(1, "cron is required"),
timezone: z
.string()
.trim()
.min(1)
.optional()
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: z.string().optional(),
},
outputSchema: ScheduleSummarySchema.shape,
},
async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
if (!callerAgentId) {
throw new Error("create_heartbeat requires an agent-scoped session");
}
resolveCallerAgent();
const expiresAt = buildScheduleExpiry(expiresIn);
const schedule = await scheduleService.create({
prompt: prompt.trim(),
cadence: buildCronScheduleCadence({
cron,
...(timezone !== undefined ? { timezone } : {}),
}),
target: { type: "agent", agentId: callerAgentId },
...(name?.trim() ? { name: name.trim() } : {}),
...(maxRuns === undefined ? {} : { maxRuns }),
...(expiresAt === undefined ? {} : { expiresAt }),
});
return {
@@ -2027,7 +2082,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cwd: z
.string()
.optional()
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
.describe("Optional repository cwd. Defaults to your current working directory."),
},
outputSchema: {
worktrees: z.array(WorktreeSummarySchema),
@@ -2099,6 +2154,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const commandResult = await createPaseoWorktreeCommand(
{
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
createPaseoWorktreeWorkflow: options.createPaseoWorktree,
},
createMcpWorktreeCommandInput(repoRoot, target),
@@ -2131,7 +2187,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cwd: z
.string()
.optional()
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
.describe("Optional repository cwd. Defaults to your current working directory."),
worktreePath: z.string().optional(),
worktreeSlug: z.string().optional(),
},
@@ -2361,6 +2417,7 @@ function archiveWorktreeDependencies(
}
return {
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager,

View File

@@ -97,6 +97,18 @@ class SessionEvents {
.map((event) => event.item);
}
timelineAndCompletionEvents() {
return this.events.flatMap((event) => {
if (event.type === "timeline") {
return [{ type: "timeline" as const, item: event.item }];
}
if (event.type === "turn_completed") {
return [{ type: "turn_completed" as const }];
}
return [];
});
}
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
@@ -459,6 +471,28 @@ describe("PiRpcAgentSession", () => {
]);
});
test("surfaces Pi extension command messages and completes when no agent turn starts", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("/show-status");
fakeSession.emit({
type: "message_end",
message: {
role: "custom",
content: [{ type: "text", text: "Extension command output" }],
},
});
expect(events.timelineAndCompletionEvents()).toEqual([
{
type: "timeline",
item: { type: "assistant_message", text: "Extension command output" },
},
{ type: "turn_completed" },
]);
});
test("adds Pi assistant context to generic provider finish errors", async () => {
const { pi, session, events } = await createSession();

View File

@@ -1499,6 +1499,20 @@ export class PiRpcAgentSession implements AgentSession {
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
turnId: string | undefined,
): void {
if (event.message.role === "custom") {
const text = getUserMessageText(event.message.content);
if (text) {
this.emit({
type: "timeline",
provider: PI_PROVIDER,
turnId,
item: { type: "assistant_message", text },
});
}
this.completeTurn(turnId, []);
return;
}
if (event.message.role !== "user") {
return;
}

View File

@@ -30,6 +30,10 @@ export type PiAgentMessage =
role: "user";
content: string | Array<PiTextContent | PiImageContent>;
}
| {
role: "custom";
content: string | Array<PiTextContent | PiImageContent>;
}
| {
role: "assistant";
content: PiAssistantContent[];

View File

@@ -15,6 +15,7 @@ import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
export interface AutoArchiveArchiveOptions {
paseoHome: string;
worktreesRoot?: string;
daemonConfigStore: DaemonConfigStore;
workspaceGitService: WorkspaceGitServiceImpl;
github: GitHubService;
@@ -81,7 +82,10 @@ export async function archiveIfSafe(input: {
return;
}
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, { paseoHome: options.paseoHome });
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, {
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
});
if (!ownership.allowed) {
return;
}
@@ -90,6 +94,7 @@ export async function archiveIfSafe(input: {
await deps.archivePaseoWorktree(
{
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager: options.agentManager,
@@ -115,6 +120,7 @@ export async function archiveIfSafe(input: {
targetPath: cwd,
repoRoot: ownership.repoRoot ?? null,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: options.worktreesRoot,
requestId: "auto-archive-on-merge",
},
);

View File

@@ -1,14 +1,18 @@
import os from "node:os";
import http from "node:http";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
describe("paseo daemon bootstrap", () => {
afterEach(() => {
@@ -41,6 +45,253 @@ describe("paseo daemon bootstrap", () => {
}
});
function httpGetWithHost(port: number, host: string, requestPath: string): Promise<Response> {
return new Promise((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
resolve(
new Response(Buffer.concat(chunks), {
status: res.statusCode ?? 0,
headers: res.headers as HeadersInit,
}),
);
});
},
);
req.on("error", reject);
});
}
test("proxies registered service hosts before daemon auth while daemon APIs stay protected", async () => {
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("service-ok");
});
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("Expected upstream TCP address");
}
const daemonHandle = await createTestPaseoDaemon({
auth: { password: hashDaemonPassword("secret") },
});
try {
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
workspaceId: "workspace-service-auth",
projectSlug: "repo",
branchName: "main",
scriptName: "web",
port: address.port,
});
const serviceResponse = await httpGetWithHost(
daemonHandle.port,
`web--repo.localhost:${daemonHandle.port}`,
"/",
);
expect(serviceResponse.status).toBe(200);
expect(await serviceResponse.text()).toBe("service-ok");
const daemonResponse = await httpGetWithHost(
daemonHandle.port,
`daemon.localhost:${daemonHandle.port}`,
"/api/status",
);
expect(daemonResponse.status).toBe(401);
} finally {
await daemonHandle.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
test("configured public service namespace misses never reach daemon APIs", async () => {
const daemonHandle = await createTestPaseoDaemon({
serviceProxy: {
publicBaseUrl: "https://services.example.com",
standaloneListen: null,
},
});
try {
const response = await httpGetWithHost(
daemonHandle.port,
`missing.services.example.com:${daemonHandle.port}`,
"/api/status",
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("404 Not Found");
} finally {
await daemonHandle.close();
}
});
test("rolls back daemon listener when standalone service proxy startup fails", async () => {
const occupiedServer = http.createServer((_req, res) => {
res.end("occupied");
});
await new Promise<void>((resolve) => occupiedServer.listen(0, "127.0.0.1", resolve));
const address = occupiedServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected occupied TCP address");
}
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-standalone-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
const config: PaseoDaemonConfig = {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
serviceProxy: {
standaloneListen: `127.0.0.1:${address.port}`,
},
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${daemon.port}/api/health`)).rejects.toThrow();
} finally {
await daemon.stop().catch(() => undefined);
await new Promise<void>((resolve) => occupiedServer.close(() => resolve()));
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
test("local service namespace misses never reach daemon APIs", async () => {
const daemonHandle = await createTestPaseoDaemon({
auth: { password: hashDaemonPassword("secret") },
});
try {
const response = await httpGetWithHost(
daemonHandle.port,
`missing--repo.localhost:${daemonHandle.port}`,
"/api/status",
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("404 Not Found");
} finally {
await daemonHandle.close();
}
});
test("daemon websocket still upgrades when service proxy upgrade handler is mounted", async () => {
const daemonHandle = await createTestPaseoDaemon();
const ws = new WebSocket(`ws://127.0.0.1:${daemonHandle.port}/ws`);
try {
await new Promise<void>((resolve, reject) => {
ws.once("open", resolve);
ws.once("error", reject);
});
expect(ws.readyState).toBe(WebSocket.OPEN);
} finally {
ws.close();
await daemonHandle.close();
}
});
test("standalone listener exposes services only", async () => {
const standalonePort = await findFreePort();
const upstream = http.createServer((_req, res) => {
res.end("service-ok");
});
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const upstreamAddress = upstream.address();
if (!upstreamAddress || typeof upstreamAddress === "string") {
throw new Error("Expected upstream TCP address");
}
const daemonHandle = await createTestPaseoDaemon({
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
});
try {
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
workspaceId: "workspace-standalone",
projectSlug: "repo",
branchName: "main",
scriptName: "web",
port: upstreamAddress.port,
});
const serviceResponse = await httpGetWithHost(
standalonePort,
`web--repo.localhost:${standalonePort}`,
"/",
);
expect(serviceResponse.status).toBe(200);
expect(await serviceResponse.text()).toBe("service-ok");
for (const requestPath of ["/api/health", "/ws", "/mcp/agents", "/index.html", "/files/x"]) {
const response = await httpGetWithHost(
standalonePort,
`daemon.localhost:${standalonePort}`,
requestPath,
);
expect(response.status).toBe(404);
}
} finally {
await daemonHandle.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
test("rolls back already-open standalone listener when main daemon listen fails", async () => {
const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => {
res.end("occupied-main");
});
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
const config: PaseoDaemonConfig = {
listen: `127.0.0.1:${mainPort}`,
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow();
} finally {
await daemon.stop().catch(() => undefined);
await new Promise<void>((resolve) => occupiedMain.close(() => resolve()));
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
test("redacts Agent MCP debug request credentials and bodies", async () => {
const logLines: string[] = [];
const logger = pino(

View File

@@ -126,11 +126,7 @@ import type {
ProviderOverride,
} from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
createScriptProxyUpgradeHandler,
} from "./script-proxy.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -231,6 +227,7 @@ export type DaemonLifecycleIntent =
export interface PaseoDaemonConfig {
listen: string;
paseoHome: string;
worktreesRoot?: string;
corsAllowedOrigins: string[];
allowedHosts?: HostnamesConfig;
hostnames?: HostnamesConfig;
@@ -248,6 +245,10 @@ export interface PaseoDaemonConfig {
relayPublicEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
serviceProxy?: {
publicBaseUrl: string | null;
standaloneListen: string | null;
};
appBaseUrl?: string;
auth?: DaemonAuthConfig;
openai?: PaseoOpenAIConfig;
@@ -276,7 +277,7 @@ export interface PaseoDaemon {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager;
scriptRouteStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
start(): Promise<void>;
stop(): Promise<void>;
@@ -330,33 +331,46 @@ export async function createPaseoDaemon(
let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const scriptRouteStore = new ScriptRouteStore();
const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl
? config.serviceProxy.publicBaseUrl
: null;
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: serviceProxyPublicBaseUrl,
});
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const configuredHostnames = config.hostnames ?? config.allowedHosts;
let wsServer: VoiceAssistantWebSocketServer | null = null;
let serviceProxyListenTarget: ListenTarget | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore,
serviceProxy,
onChange: createScriptStatusEmitter({
sessions: () =>
wsServer?.listActiveSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message),
})) ?? [],
routeStore: scriptRouteStore,
serviceProxy,
runtimeStore: scriptRuntimeStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
resolveWorkspaceDirectory: async (workspaceId) =>
(await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
logger,
serviceProxyPublicBaseUrl,
}),
});
const handleBranchChange = createBranchChangeRouteHandler({
routeStore: scriptRouteStore,
serviceProxy,
onRoutesChanged: (workspaceId) => {
scriptHealthMonitor.invalidateWorkspace(workspaceId);
},
logger,
});
// Service proxy classifies service hosts before daemon auth/route fallthrough.
// Registered service hosts proxy directly; known service namespaces without a
// route return 404 and never reach daemon APIs.
app.use(serviceProxy.middleware());
// Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation.
if (listenTarget.type === "tcp") {
@@ -406,11 +420,6 @@ export async function createPaseoDaemon(
}),
);
// Script proxy — intercepts requests for registered *.localhost hostnames
// and forwards them to the corresponding local script port. Placed after
// host/CORS/auth checks but before the rest of the routes.
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
// Serve static files from public directory
app.use("/public", express.static(staticDir));
@@ -494,11 +503,11 @@ export async function createPaseoDaemon(
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
// script-bound upgrades are forwarded first. The handler is a no-op for
// requests that don't match a registered script route.
const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({
routeStore: scriptRouteStore,
logger,
});
httpServer.on("upgrade", scriptProxyUpgradeHandler);
httpServer.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: true }));
if (config.serviceProxy?.standaloneListen) {
serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
}
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
@@ -518,6 +527,7 @@ export async function createPaseoDaemon(
const workspaceGitService = new WorkspaceGitServiceImpl({
logger,
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
deps: {
github,
},
@@ -657,6 +667,7 @@ export async function createPaseoDaemon(
setupAutoArchiveOnMerge({
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
daemonConfigStore,
workspaceGitService,
github,
@@ -694,6 +705,7 @@ export async function createPaseoDaemon(
return createPaseoWorktreeWorkflow(
{
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
createPaseoWorktree: async (workflowInput, workflowOptions) => {
return createRegisteredPaseoWorktree(workflowInput, {
github,
@@ -727,12 +739,13 @@ export async function createPaseoDaemon(
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
getDaemonTcpHost: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
serviceProxyPublicBaseUrl,
onScriptsChanged: null,
},
input,
@@ -740,6 +753,7 @@ export async function createPaseoDaemon(
);
},
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
callerAgentId,
enableVoiceTools: false,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
@@ -872,157 +886,185 @@ export async function createPaseoDaemon(
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
const start = async () => {
// Start main HTTP server
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
httpServer.off("listening", onListening);
reject(err);
};
const onListening = () => {
httpServer.off("error", onError);
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
});
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
});
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (boundListenTarget.type === "tcp") {
logger.info(
{
host: boundListenTarget.host,
port: boundListenTarget.port,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
);
} else {
logger.info(
{
path: boundListenTarget.path,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on ${boundListenTarget.path}`,
);
}
if (config.auth?.password) {
logger.info("Daemon password authentication enabled");
}
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
logger,
serverId,
agentManager,
agentStorage,
downloadTokenStore,
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
config.auth,
speechService,
terminalManager,
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
},
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
checkoutDiffManager,
scriptRouteStore,
scriptRuntimeStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
workspaceGitService,
github,
config.pushNotificationSender,
providerSnapshotManager,
{
listen: formatListenTarget(boundListenTarget ?? listenTarget),
relay: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
},
);
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: {
endpoint: relayPublicEndpoint,
useTls: relayPublicUseTls,
},
});
encodeOfferToFragmentUrl({ offer, appBaseUrl });
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
relayUseTls,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
};
logAndResolve().then(resolve, reject);
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host);
} else {
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
httpServer.listen(listenTarget.path);
let mainStarted = false;
try {
if (serviceProxyListenTarget) {
const boundServiceProxyTarget = await serviceProxy.startStandalone({
listenTarget: serviceProxyListenTarget,
});
serviceProxyListenTarget = boundServiceProxyTarget;
logger.info(
{
listen: formatListenTarget(serviceProxyListenTarget),
publicBaseUrl: serviceProxyPublicBaseUrl,
elapsed: elapsed(),
},
"Service proxy listening",
);
}
});
// Start speech service after listening so synchronous Sherpa native
// model loading doesn't block the server from accepting connections.
speechService.start();
scriptHealthMonitor.start();
// Start main HTTP server
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
httpServer.off("listening", onListening);
reject(err);
};
const onListening = () => {
httpServer.off("error", onError);
mainStarted = true;
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
});
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
});
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (boundListenTarget.type === "tcp") {
logger.info(
{
host: boundListenTarget.host,
port: boundListenTarget.port,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
);
} else {
logger.info(
{
path: boundListenTarget.path,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on ${boundListenTarget.path}`,
);
}
if (config.auth?.password) {
logger.info("Daemon password authentication enabled");
}
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
logger,
serverId,
agentManager,
agentStorage,
downloadTokenStore,
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
config.auth,
speechService,
terminalManager,
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
},
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
checkoutDiffManager,
serviceProxy,
scriptRuntimeStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
workspaceGitService,
github,
config.pushNotificationSender,
providerSnapshotManager,
{
listen: formatListenTarget(boundListenTarget ?? listenTarget),
worktreesRoot: config.worktreesRoot,
relay: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
},
serviceProxyPublicBaseUrl,
);
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: {
endpoint: relayPublicEndpoint,
useTls: relayPublicUseTls,
},
});
encodeOfferToFragmentUrl({ offer, appBaseUrl });
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
relayUseTls,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
};
logAndResolve().then(resolve, reject);
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host);
} else {
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
httpServer.listen(listenTarget.path);
}
});
// Start speech service after listening so synchronous Sherpa native
// model loading doesn't block the server from accepting connections.
speechService.start();
scriptHealthMonitor.start();
} catch (error) {
await serviceProxy.stopStandalone().catch(() => undefined);
if (mainStarted) {
httpServer.closeAllConnections();
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
}
throw error;
}
};
const stop = async () => {
@@ -1039,6 +1081,7 @@ export async function createPaseoDaemon(
if (wsServer) {
await wsServer.close();
}
await serviceProxy.stopStandalone();
// Force-drop remaining sockets so httpServer.close() resolves promptly.
// We've already closed wsServer (which sent ws-layer close frames) and
// stopped every other service, so anything still attached is a TCP
@@ -1061,7 +1104,7 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
start,
stop,

View File

@@ -83,3 +83,95 @@ describe("daemon relay config", () => {
expect(config.relayPublicUseTls).toBe(true);
});
});
describe("daemon service proxy config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("loads public base URL from env before persisted config", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
serviceProxy: {
publicBaseUrl: "https://persisted.example.com",
},
},
});
const config = loadConfig(home, {
env: { PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: "https://env.example.com/" },
});
expect(config.serviceProxy).toEqual({
publicBaseUrl: "https://env.example.com",
standaloneListen: null,
});
});
test("does not synthesize a standalone service listener from enabled true", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { serviceProxy: { enabled: true } },
});
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
publicBaseUrl: null,
standaloneListen: null,
});
});
test("enabled false suppresses optional service proxy layers only", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
serviceProxy: {
enabled: false,
listen: "127.0.0.1:9999",
publicBaseUrl: "https://persisted.example.com",
},
},
});
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
publicBaseUrl: null,
standaloneListen: null,
});
});
test("rejects invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL values", async () => {
const home = await createPaseoHome({ version: 1 });
expect(() =>
loadConfig(home, {
env: { PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: "not-a-url" },
}),
).toThrow("Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: not-a-url");
});
});
describe("daemon worktree root config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("resolves relative worktrees.root against PASEO_HOME", async () => {
const home = await createPaseoHome({
version: 1,
worktrees: { root: "custom-worktrees" },
});
expect(loadConfig(home, { env: {} }).worktreesRoot).toBe(path.join(home, "custom-worktrees"));
});
test("keeps absolute worktrees.root absolute", async () => {
const home = await createPaseoHome({
version: 1,
worktrees: { root: path.join(os.tmpdir(), "paseo-custom-worktrees") },
});
expect(loadConfig(home, { env: {} }).worktreesRoot).toBe(
path.join(os.tmpdir(), "paseo-custom-worktrees"),
);
});
});

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { resolvePaseoNodeEnv } from "./paseo-env.js";
import { z } from "zod";
import { expandTilde } from "../utils/path.js";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import {
@@ -151,6 +152,11 @@ interface ResolvedRelay {
publicUseTls: boolean;
}
interface ResolvedServiceProxy {
publicBaseUrl: string | null;
standaloneListen: string | null;
}
function resolveTlsFromEnv(
envValue: string | undefined,
persistedValue: boolean | undefined,
@@ -197,6 +203,41 @@ interface ResolvedVoiceLlm {
model: string | null;
}
function resolveServiceProxyPublicBaseUrl(value: string | null): string | null {
if (value === null) {
return null;
}
try {
return new URL(value).toString().replace(/\/$/, "");
} catch {
throw new Error(`Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: ${value}`);
}
}
function resolveServiceProxyConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): ResolvedServiceProxy {
const enabledShim =
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? persisted.daemon?.serviceProxy?.enabled;
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
// `enabled=false` used to disable the separate service proxy listener. Localhost
// service proxying is now always enabled; this only suppresses optional layers.
const optionalLayersEnabled = enabledShim !== false;
const publicBaseUrl = optionalLayersEnabled
? resolveServiceProxyPublicBaseUrl(
env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
persisted.daemon?.serviceProxy?.publicBaseUrl ??
null,
)
: null;
const standaloneListen = optionalLayersEnabled
? (env.PASEO_SERVICE_PROXY_LISTEN ?? persisted.daemon?.serviceProxy?.listen ?? null)
: null;
return { publicBaseUrl, standaloneListen };
}
function resolveVoiceLlmConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
@@ -256,6 +297,21 @@ function resolveAuthConfig(
: undefined;
}
function resolveWorktreesRoot(
paseoHome: string,
persisted: ReturnType<typeof loadPersistedConfig>,
): string | undefined {
const configuredRoot = persisted.worktrees?.root?.trim();
if (!configuredRoot) {
return undefined;
}
const expandedRoot = expandTilde(configuredRoot);
return path.isAbsolute(expandedRoot)
? path.resolve(expandedRoot)
: path.resolve(paseoHome, expandedRoot);
}
function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedConfig>): string {
return persisted.daemon?.appendSystemPrompt ?? "";
}
@@ -306,6 +362,7 @@ export function loadConfig(
cliRelayEnabled: options?.cli?.relayEnabled,
cliRelayUseTls: options?.cli?.relayUseTls,
});
const serviceProxy = resolveServiceProxyConfig(env, persisted);
const { openai, speech } = resolveSpeechConfig({
paseoHome,
@@ -321,6 +378,7 @@ export function loadConfig(
return {
listen,
paseoHome,
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
hostnames,
mcpEnabled,
@@ -337,6 +395,7 @@ export function loadConfig(
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
relayPublicUseTls: relay.publicUseTls,
serviceProxy,
appBaseUrl,
auth: resolveAuthConfig(env, persisted),
openai,

View File

@@ -5,11 +5,16 @@ import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import type { GitHubService } from "../services/github-service.js";
import { deletePaseoWorktree, resolvePaseoWorktreeRootForCwd } from "../utils/worktree.js";
import {
deletePaseoWorktree,
resolvePaseoWorktreeRootForCwd,
WorktreeTeardownError,
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
export interface ArchivePaseoWorktreeDependencies {
paseoHome?: string;
worktreesRoot?: string;
github: GitHubService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
@@ -37,12 +42,14 @@ export async function archivePaseoWorktree(
targetPath: string;
repoRoot: string | null;
worktreesRoot?: string;
worktreesBaseRoot?: string;
requestId: string;
},
): Promise<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
@@ -104,14 +111,28 @@ export async function archivePaseoWorktree(
}
}
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome,
});
let teardownError: WorktreeTeardownError | null = null;
try {
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
});
} catch (error) {
if (error instanceof WorktreeTeardownError) {
teardownError = error;
dependencies.sessionLogger?.warn(
{ err: error, targetPath },
"Worktree teardown failed during archive; archiving workspace record anyway",
);
} else {
throw error;
}
}
if (options.repoRoot) {
if (!teardownError && options.repoRoot) {
try {
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
force: true,
@@ -136,11 +157,17 @@ export async function archivePaseoWorktree(
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to archive workspace record; worktree FS already removed",
teardownError
? "Failed to archive workspace record after teardown failed"
: "Failed to archive workspace record; worktree FS already removed",
);
}
}),
);
if (teardownError) {
throw teardownError;
}
} finally {
dependencies.clearWorkspaceArchiving(affectedWorkspaceIdList);
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList);

View File

@@ -63,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => {
});
});
describe("PersistedConfigSchema worktrees config", () => {
test("accepts optional worktree root", () => {
const parsed = PersistedConfigSchema.parse({
worktrees: {
root: "/mnt/fast/paseo-worktrees",
},
});
expect(parsed.worktrees?.root).toBe("/mnt/fast/paseo-worktrees");
});
});
describe("PersistedConfigSchema daemon append system prompt", () => {
test("accepts optional append system prompt", () => {
const parsed = PersistedConfigSchema.parse({

View File

@@ -63,6 +63,12 @@ const ProvidersSchema = z
})
.strict();
const WorktreesConfigSchema = z
.object({
root: z.string().min(1).optional(),
})
.strict();
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
message: "Expected a bcrypt hash",
});
@@ -231,6 +237,17 @@ export const PersistedConfigSchema = z
})
.strict()
.optional(),
serviceProxy: z
.object({
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
// Parsed only to suppress optional public/listen layers for old configs;
// localhost service proxying remains always enabled.
enabled: z.boolean().optional(),
listen: z.string().optional(),
publicBaseUrl: z.string().url().optional(),
})
.strict()
.optional(),
auth: DaemonAuthSchema.optional(),
})
.strict()
@@ -248,6 +265,7 @@ export const PersistedConfigSchema = z
.optional(),
providers: ProvidersSchema.optional(),
worktrees: WorktreesConfigSchema.optional(),
agents: z
.object({
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),

View File

@@ -162,7 +162,7 @@ describe("ScriptHealthMonitor", () => {
const routeStore = new ScriptRouteStore();
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -214,7 +214,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -261,7 +261,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -292,7 +292,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -343,7 +343,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -393,7 +393,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -450,7 +450,7 @@ describe("ScriptHealthMonitor", () => {
branchName: null,
scriptName,
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(
createTerminalCalls,
@@ -462,7 +462,7 @@ describe("ScriptHealthMonitor", () => {
expect(createTerminalCalls).toHaveLength(2);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: service.port,
workspaceId: workspace.repoDir,
projectSlug: "repo",
@@ -472,7 +472,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -487,7 +487,7 @@ describe("ScriptHealthMonitor", () => {
expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [
{
scriptName: "api",
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: service.port,
health: "healthy",
},
@@ -524,7 +524,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,

View File

@@ -1,5 +1,5 @@
import net from "node:net";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js";
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
@@ -18,7 +18,7 @@ interface RouteHealthState {
}
export class ScriptHealthMonitor {
private readonly routeStore: ScriptRouteStore;
private readonly serviceProxy: ServiceProxySubsystem;
private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
private readonly pollIntervalMs: number;
private readonly probeTimeoutMs: number;
@@ -31,21 +31,21 @@ export class ScriptHealthMonitor {
private pollInFlight = false;
constructor({
routeStore,
serviceProxy,
onChange,
pollIntervalMs = 3_000,
probeTimeoutMs = 500,
graceMs = 5_000,
failuresBeforeStopped = 2,
}: {
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
pollIntervalMs?: number;
probeTimeoutMs?: number;
graceMs?: number;
failuresBeforeStopped?: number;
}) {
this.routeStore = routeStore;
this.serviceProxy = serviceProxy;
this.onChange = onChange;
this.pollIntervalMs = pollIntervalMs;
this.probeTimeoutMs = probeTimeoutMs;
@@ -59,7 +59,7 @@ export class ScriptHealthMonitor {
}
const now = Date.now();
for (const route of this.routeStore.listRoutes()) {
for (const route of this.serviceProxy.getHealthCheckTargets()) {
this.getOrCreateState(route, now);
}
@@ -93,7 +93,7 @@ export class ScriptHealthMonitor {
this.pollInFlight = true;
try {
const routes = this.routeStore.listRoutes();
const routes = this.serviceProxy.getHealthCheckTargets();
const activeHostnames = new Set(routes.map((route) => route.hostname));
const changedWorkspaceIds = new Set<string>();
const now = Date.now();
@@ -142,7 +142,7 @@ export class ScriptHealthMonitor {
}
private getOrCreateState(
route: Pick<ScriptRouteEntry, "hostname" | "workspaceId">,
route: Pick<ServiceProxyHealthTarget, "hostname" | "workspaceId">,
registeredAt: number,
): RouteHealthState {
const existing = this.routeStates.get(route.hostname);
@@ -171,7 +171,7 @@ export class ScriptHealthMonitor {
}
private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] {
return this.routeStore.listRoutesForWorkspace(workspaceId).flatMap((route) => {
return this.serviceProxy.getWorkspaceHealthTargets(workspaceId).flatMap((route) => {
const state = this.routeStates.get(route.hostname);
if (!state) {
return [];
@@ -186,7 +186,7 @@ export class ScriptHealthMonitor {
return state.health;
}
const route = this.routeStore.getRouteEntry(hostname);
const route = this.serviceProxy.getHealthTargetForHostname(hostname);
if (!route) {
return null;
}
@@ -195,7 +195,7 @@ export class ScriptHealthMonitor {
}
private toScriptHealthEntry(
route: ScriptRouteEntry,
route: ServiceProxyHealthTarget,
health: ScriptHealthEntry["health"],
): ScriptHealthEntry {
return {

View File

@@ -1,484 +1,16 @@
import { it, expect, afterEach } from "vitest";
import http from "node:http";
import net from "node:net";
import express from "express";
import { WebSocket, WebSocketServer } from "ws";
import pino from "pino";
import { describe, expect, it } from "vitest";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
createScriptProxyUpgradeHandler,
findFreePort,
ScriptRouteStore,
} from "./script-proxy.js";
const logger = pino({ level: "silent" });
// ---------------------------------------------------------------------------
// Helpers for cleanup
// ---------------------------------------------------------------------------
function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
// ---------------------------------------------------------------------------
// ScriptRouteStore
// ---------------------------------------------------------------------------
it("registerRoute and findRoute with exact match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
});
it("findRoute strips port from host header", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost:6767");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
});
it("findRoute subdomain match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "editor.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("tenant.editor.example.localhost");
expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 });
});
it("listRoutes returns enriched entries", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
const routes = store.listRoutes();
expect(routes).toHaveLength(2);
expect(routes).toContainEqual({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
expect(routes).toContainEqual({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
describe("script-proxy compatibility re-exports", () => {
it("keeps the legacy imports available", () => {
expect(createScriptProxyMiddleware).toEqual(expect.any(Function));
expect(createScriptProxyUpgradeHandler).toEqual(expect.any(Function));
expect(findFreePort).toEqual(expect.any(Function));
expect(new ScriptRouteStore()).toEqual(expect.any(Object));
});
});
it("listRoutesForWorkspace returns only routes for that workspace", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
store.registerRoute({
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
{
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
},
{
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
},
]);
});
it("removeRoute works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoute("route-a.example.localhost");
expect(store.findRoute("route-a.example.localhost")).toBeNull();
});
it("removeRoute cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoute("route-a.example.localhost");
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("removeRoutesForPort works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.registerRoute({
hostname: "c.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
store.removeRoutesForPort(3000);
expect(store.findRoute("a.localhost")).toBeNull();
expect(store.findRoute("b.localhost")).toBeNull();
expect(store.findRoute("c.localhost")).toEqual({
hostname: "c.localhost",
port: 4000,
});
});
it("removeRoutesForPort cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.removeRoutesForPort(3000);
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("findRoute returns null for unknown hosts", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
expect(store.findRoute("unknown.example.com")).toBeNull();
});
// ---------------------------------------------------------------------------
// HTTP proxy
// ---------------------------------------------------------------------------
const servers: http.Server[] = [];
afterEach(async () => {
await Promise.all(servers.map(closeServer));
servers.length = 0;
});
/** Start a real HTTP server that echoes back a known body and records received headers. */
async function startUpstream(): Promise<{
port: number;
server: http.Server;
receivedHeaders: () => http.IncomingHttpHeaders;
}> {
const port = await findFreePort();
let lastHeaders: http.IncomingHttpHeaders = {};
const server = http.createServer((req, res) => {
lastHeaders = req.headers;
res.writeHead(200, { "content-type": "text/plain" });
res.end("upstream-ok");
});
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
return {
port,
server,
receivedHeaders: () => lastHeaders,
};
}
/** Start an Express app with the service proxy middleware and an optional fallback. */
async function startProxy(
routeStore: ScriptRouteStore,
opts?: { fallback?: boolean },
): Promise<{ port: number; server: http.Server }> {
const port = await findFreePort();
const app = express();
app.use(createScriptProxyMiddleware({ routeStore, logger }));
if (opts?.fallback) {
app.use((_req, res) => {
res.status(404).send("no route");
});
}
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
return { port, server };
}
/** Simple HTTP GET helper that returns status code and body. */
function httpGet(
port: number,
host: string,
path = "/",
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => {
let body = "";
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
});
req.on("error", reject);
});
}
it("proxies requests to the correct upstream based on Host header", async () => {
const upstream = await startUpstream();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "test-service.localhost",
port: upstream.port,
workspaceId: "workspace-test",
projectSlug: "test",
scriptName: "service",
});
const proxy = await startProxy(routeStore);
const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`);
expect(res.status).toBe(200);
expect(res.body).toBe("upstream-ok");
const headers = upstream.receivedHeaders();
expect(headers["x-forwarded-for"]).toBeDefined();
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
});
it("falls through when no route matches", async () => {
const routeStore = new ScriptRouteStore();
const proxy = await startProxy(routeStore, { fallback: true });
const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`);
expect(res.status).toBe(404);
expect(res.body).toBe("no route");
});
it("returns 502 when upstream is down", async () => {
// Get a port that nothing is listening on
const deadPort = await findFreePort();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "dead-service.localhost",
port: deadPort,
workspaceId: "workspace-dead",
projectSlug: "dead",
scriptName: "service",
});
const proxy = await startProxy(routeStore);
const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`);
expect(res.status).toBe(502);
expect(res.body).toBe("502 Bad Gateway");
});
// ---------------------------------------------------------------------------
// WebSocket proxy
// ---------------------------------------------------------------------------
const httpServers: http.Server[] = [];
const wsServers: WebSocketServer[] = [];
const wsClients: WebSocket[] = [];
afterEach(async () => {
for (const ws of wsClients) {
if (ws.readyState === WebSocket.OPEN) ws.close();
}
wsClients.length = 0;
for (const wss of wsServers) {
wss.close();
}
wsServers.length = 0;
await Promise.all(httpServers.map(closeServer));
httpServers.length = 0;
});
it("proxies WebSocket connections to the correct upstream", async () => {
// 1. Start a real WebSocket echo server
const upstreamPort = await findFreePort();
const upstreamServer = http.createServer();
const wss = new WebSocketServer({ server: upstreamServer });
wsServers.push(wss);
wss.on("connection", (ws) => {
ws.on("message", (data) => {
ws.send(`echo: ${data.toString()}`);
});
});
await new Promise<void>((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve));
httpServers.push(upstreamServer);
// 2. Create the proxy server with the upgrade handler
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "ws-service.localhost",
port: upstreamPort,
workspaceId: "workspace-ws",
projectSlug: "ws",
scriptName: "service",
});
const proxyPort = await findFreePort();
const proxyServer = http.createServer((_req, res) => {
res.writeHead(404);
res.end();
});
const upgradeHandler = createScriptProxyUpgradeHandler({
routeStore,
logger,
});
proxyServer.on("upgrade", upgradeHandler);
await new Promise<void>((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve));
httpServers.push(proxyServer);
// 3. Connect a WebSocket client through the proxy
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
headers: { host: `ws-service.localhost:${proxyPort}` },
});
wsClients.push(ws);
await new Promise<void>((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
// 4. Send a message and verify echo
const reply = await new Promise<string>((resolve, reject) => {
ws.on("message", (data) => resolve(data.toString()));
ws.on("error", reject);
ws.send("hello proxy");
});
expect(reply).toBe("echo: hello proxy");
});
// ---------------------------------------------------------------------------
// findFreePort
// ---------------------------------------------------------------------------
it("returns a number", async () => {
const port = await findFreePort();
expect(typeof port).toBe("number");
expect(port).toBeGreaterThan(0);
expect(port).toBeLessThan(65536);
});
it("returns a port that is actually available", async () => {
const port = await findFreePort();
// Verify we can bind a server to it
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.listen(port, "127.0.0.1", () => resolve());
server.on("error", reject);
});
const addr = server.address();
expect(addr).not.toBeNull();
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port);
await new Promise<void>((resolve) => server.close(() => resolve()));
});

View File

@@ -1,320 +1,7 @@
import http from "node:http";
import net from "node:net";
import type { IncomingMessage } from "node:http";
import type { Logger } from "pino";
import type { RequestHandler } from "express";
// ---------------------------------------------------------------------------
// Hop-by-hop headers that must not be forwarded
// ---------------------------------------------------------------------------
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"transfer-encoding",
"keep-alive",
"upgrade",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
]);
// ---------------------------------------------------------------------------
// ScriptRouteStore
// ---------------------------------------------------------------------------
export interface ScriptRoute {
hostname: string;
port: number;
}
export interface ScriptRouteEntry extends ScriptRoute {
workspaceId: string;
projectSlug: string;
scriptName: string;
}
export class ScriptRouteStore {
private routes = new Map<string, ScriptRouteEntry>();
private workspaceHostnames = new Map<string, Set<string>>();
registerRoute(entry: ScriptRouteEntry): void {
const previous = this.routes.get(entry.hostname);
if (previous) {
this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname);
}
const storedEntry = { ...entry };
this.routes.set(storedEntry.hostname, storedEntry);
this.addHostnameToWorkspaceIndex(storedEntry.workspaceId, storedEntry.hostname);
}
removeRoute(hostname: string): void {
const entry = this.routes.get(hostname);
if (!entry) {
return;
}
this.routes.delete(hostname);
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
}
removeRouteForWorkspaceScript(params: { workspaceId: string; scriptName: string }): void {
const routes = this.listRoutesForWorkspace(params.workspaceId);
const route = routes.find((entry) => entry.scriptName === params.scriptName);
if (!route) {
return;
}
this.removeRoute(route.hostname);
}
removeRoutesForPort(port: number): void {
for (const [hostname, entry] of this.routes) {
if (entry.port === port) {
this.routes.delete(hostname);
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
}
}
}
findRoute(host: string): ScriptRoute | null {
// Strip port suffix from the Host header value
const hostname = host.replace(/:\d+$/, "");
// 1. Exact match
const exactRoute = this.routes.get(hostname);
if (exactRoute !== undefined) {
return { hostname: exactRoute.hostname, port: exactRoute.port };
}
// 2. Subdomain match — walk up the labels looking for a registered parent
const parts = hostname.split(".");
for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(i).join(".");
const candidateRoute = this.routes.get(candidate);
if (candidateRoute !== undefined) {
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
}
}
return null;
}
getRouteEntry(hostname: string): ScriptRouteEntry | null {
const entry = this.routes.get(hostname);
return entry ? { ...entry } : null;
}
listRoutes(): ScriptRouteEntry[] {
return Array.from(this.routes.values()).map((entry) => Object.assign({}, entry));
}
listRoutesForWorkspace(workspaceId: string): ScriptRouteEntry[] {
const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) {
return [];
}
const routes: ScriptRouteEntry[] = [];
for (const hostname of hostnames) {
const entry = this.routes.get(hostname);
if (entry) {
routes.push({ ...entry });
}
}
return routes;
}
private addHostnameToWorkspaceIndex(workspaceId: string, hostname: string): void {
const hostnames = this.workspaceHostnames.get(workspaceId) ?? new Set<string>();
hostnames.add(hostname);
this.workspaceHostnames.set(workspaceId, hostnames);
}
private removeHostnameFromWorkspaceIndex(workspaceId: string, hostname: string): void {
const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) {
return;
}
hostnames.delete(hostname);
if (hostnames.size === 0) {
this.workspaceHostnames.delete(workspaceId);
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripHopByHopHeaders(
rawHeaders: http.IncomingHttpHeaders,
): Record<string, string | string[]> {
const out: Record<string, string | string[]> = {};
for (const [key, value] of Object.entries(rawHeaders)) {
if (value === undefined) continue;
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
out[key] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// createScriptProxyMiddleware
// ---------------------------------------------------------------------------
export function createScriptProxyMiddleware({
routeStore,
logger,
}: {
routeStore: ScriptRouteStore;
logger: Logger;
}): RequestHandler {
return (req, res, next) => {
const hostHeader = req.headers.host;
if (!hostHeader) {
next();
return;
}
const route = routeStore.findRoute(hostHeader);
if (!route) {
next();
return;
}
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = req.protocol;
const proxyReq = http.request(
{
hostname: "127.0.0.1",
port: route.port,
path: req.originalUrl,
method: req.method,
headers: forwardedHeaders,
},
(proxyRes) => {
const responseHeaders = stripHopByHopHeaders(proxyRes.headers);
res.writeHead(proxyRes.statusCode ?? 502, responseHeaders);
proxyRes.pipe(res, { end: true });
},
);
proxyReq.on("error", (err) => {
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Script proxy: upstream unreachable",
);
if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain" });
res.end("502 Bad Gateway");
}
});
req.pipe(proxyReq, { end: true });
};
}
// ---------------------------------------------------------------------------
// createScriptProxyUpgradeHandler
// ---------------------------------------------------------------------------
export function createScriptProxyUpgradeHandler({
routeStore,
logger,
}: {
routeStore: ScriptRouteStore;
logger: Logger;
}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void {
return (req, socket, head) => {
const hostHeader = req.headers.host;
if (!hostHeader) {
return;
}
const route = routeStore.findRoute(hostHeader);
if (!route) {
return;
}
const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => {
// Reconstruct the raw HTTP upgrade request to send to the target
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = "http";
// Re-include upgrade and connection headers — they are required for
// WebSocket handshake even though they are hop-by-hop.
forwardedHeaders["connection"] = "Upgrade";
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
const headerLines: string[] = [];
headerLines.push(`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`);
for (const [key, value] of Object.entries(forwardedHeaders)) {
if (Array.isArray(value)) {
for (const v of value) {
headerLines.push(`${key}: ${v}`);
}
} else {
headerLines.push(`${key}: ${value}`);
}
}
headerLines.push("\r\n");
targetSocket.write(headerLines.join("\r\n"));
if (head.length > 0) {
targetSocket.write(head);
}
// Pipe in both directions
targetSocket.pipe(socket);
socket.pipe(targetSocket);
});
targetSocket.on("error", (err) => {
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Script proxy: WebSocket upstream unreachable",
);
socket.end();
});
socket.on("error", () => {
targetSocket.destroy();
});
};
}
// ---------------------------------------------------------------------------
// findFreePort
// ---------------------------------------------------------------------------
export function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to get assigned port"));
return;
}
const { port } = address;
server.close((err) => {
if (err) {
reject(err);
} else {
resolve(port);
}
});
});
server.on("error", reject);
});
}
export {
createScriptProxyMiddleware,
createScriptProxyUpgradeHandler,
findFreePort,
ScriptRouteStore,
} from "./service-proxy.js";
export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js";

View File

@@ -49,12 +49,16 @@ function registerRoute(
workspaceId = "workspace-a",
projectSlug = "paseo",
scriptName,
publicHostname,
publicBaseUrl,
}: {
hostname: string;
port: number;
workspaceId?: string;
projectSlug?: string;
scriptName: string;
publicHostname?: string | null;
publicBaseUrl?: string | null;
},
): void {
routeStore.registerRoute({
@@ -63,6 +67,8 @@ function registerRoute(
workspaceId,
projectSlug,
scriptName,
...(publicHostname ? { publicHostname } : {}),
...(publicBaseUrl ? { publicBaseUrl } : {}),
});
}
@@ -70,22 +76,22 @@ describe("script-route-branch-handler", () => {
it("updates routes on branch rename by removing old hostnames and registering new ones", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost",
hostname: "api--feature-auth--paseo.localhost",
port: 3001,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.findRoute("api.feature-auth.paseo.localhost")).toBeNull();
expect(routeStore.findRoute("api.feature-billing.paseo.localhost")).toEqual({
hostname: "api.feature-billing.paseo.localhost",
expect(routeStore.findRoute("api--feature-auth--paseo.localhost")).toBeNull();
expect(routeStore.findRoute("api--feature-billing--paseo.localhost")).toEqual({
hostname: "api--feature-billing--paseo.localhost",
port: 3001,
});
});
@@ -94,7 +100,7 @@ describe("script-route-branch-handler", () => {
const routeStore = new ScriptRouteStore();
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -107,14 +113,14 @@ describe("script-route-branch-handler", () => {
it("is a no-op when the resolved hostnames do not change", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.paseo.localhost",
hostname: "api--paseo.localhost",
port: 3001,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -122,7 +128,7 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api.paseo.localhost",
hostname: "api--paseo.localhost",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "paseo",
@@ -135,14 +141,14 @@ describe("script-route-branch-handler", () => {
it("triggers shared reprojection after a route change", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost",
hostname: "api--feature-auth--paseo.localhost",
port: 3001,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -151,20 +157,56 @@ describe("script-route-branch-handler", () => {
expect(onRoutesChanged).toHaveBeenCalledWith("workspace-a");
});
it("updates public route aliases from the stored public base URL", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api--feature-auth--paseo.localhost",
publicHostname: "api--feature-auth--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443",
port: 3001,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
serviceProxy: routeStore,
onRoutesChanged,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.findRoute("api--feature-auth--paseo.services.example.com")).toBeNull();
expect(routeStore.findRoute("api--feature-billing--paseo.services.example.com")).toEqual({
hostname: "api--feature-billing--paseo.localhost",
port: 3001,
});
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api--feature-billing--paseo.localhost",
publicHostname: "api--feature-billing--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "paseo",
scriptName: "api",
},
]);
});
it("updates all services for a workspace when multiple routes are registered", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost",
hostname: "api--feature-auth--paseo.localhost",
port: 3001,
scriptName: "api",
});
registerRoute(routeStore, {
hostname: "web.feature-auth.paseo.localhost",
hostname: "web--feature-auth--paseo.localhost",
port: 3002,
scriptName: "web",
});
registerRoute(routeStore, {
hostname: "docs.docs-app.localhost",
hostname: "docs--docs-app.localhost",
port: 3003,
workspaceId: "workspace-b",
projectSlug: "docs-app",
@@ -173,7 +215,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -181,14 +223,14 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api.feature-billing.paseo.localhost",
hostname: "api--feature-billing--paseo.localhost",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "paseo",
scriptName: "api",
},
{
hostname: "web.feature-billing.paseo.localhost",
hostname: "web--feature-billing--paseo.localhost",
port: 3002,
workspaceId: "workspace-a",
projectSlug: "paseo",
@@ -197,7 +239,7 @@ describe("script-route-branch-handler", () => {
]);
expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([
{
hostname: "docs.docs-app.localhost",
hostname: "docs--docs-app.localhost",
port: 3003,
workspaceId: "workspace-b",
projectSlug: "docs-app",
@@ -209,14 +251,14 @@ describe("script-route-branch-handler", () => {
it("does not emit a status update when no changes are needed", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "web.paseo.localhost",
hostname: "web--paseo.localhost",
port: 3002,
scriptName: "web",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -237,7 +279,7 @@ describe("script-route-branch-handler", () => {
});
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.repo.localhost",
hostname: "api--feature-auth--repo.localhost",
port: 3001,
workspaceId: workspace.repoDir,
projectSlug: "repo",
@@ -246,7 +288,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -255,7 +297,7 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([
{
hostname: "api.feature-billing.repo.localhost",
hostname: "api--feature-billing--repo.localhost",
port: 3001,
workspaceId: workspace.repoDir,
projectSlug: "repo",
@@ -267,4 +309,99 @@ describe("script-route-branch-handler", () => {
workspace.cleanup();
}
});
it("leaves existing local and public routes intact when a branch rename collides", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api--feature-auth--repo.localhost",
publicHostname: "api--feature-auth--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
registerRoute(routeStore, {
hostname: "api--feature-billing--repo.localhost",
publicHostname: "api--feature-billing--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 4001,
workspaceId: "workspace-b",
projectSlug: "repo",
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
serviceProxy: routeStore,
onRoutesChanged,
});
expect(() => handleBranchChange("workspace-a", "feature/auth", "feature/billing")).toThrow(
"Service proxy hostname collision",
);
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api--feature-auth--repo.localhost",
publicHostname: "api--feature-auth--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
},
]);
expect(routeStore.getRouteEntry("api--feature-auth--repo.services.example.com")).toMatchObject({
workspaceId: "workspace-a",
port: 3001,
});
expect(
routeStore.getRouteEntry("api--feature-billing--repo.services.example.com"),
).toMatchObject({
workspaceId: "workspace-b",
port: 4001,
});
expect(onRoutesChanged).not.toHaveBeenCalled();
});
it("leaves old routes intact when branch rename creates an internal incoming collision", () => {
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "api--feature-one--repo.localhost",
publicHostname: "api--feature-one--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
routeStore.registerRoute({
hostname: "api--feature-two--repo.localhost",
publicHostname: "api--feature-two--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3002,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
serviceProxy: routeStore,
onRoutesChanged,
});
expect(() => handleBranchChange("workspace-a", "feature/one", "feature/collide")).toThrow(
"Service proxy hostname collision",
);
expect(routeStore.getRouteEntry("api--feature-one--repo.localhost")).toMatchObject({
port: 3001,
});
expect(routeStore.getRouteEntry("api--feature-two--repo.localhost")).toMatchObject({
port: 3002,
});
expect(routeStore.getRouteEntry("api--feature-collide--repo.localhost")).toBeNull();
expect(onRoutesChanged).not.toHaveBeenCalled();
});
});

View File

@@ -1,68 +1,24 @@
import type { Logger } from "pino";
import { buildScriptHostname } from "../utils/script-hostname.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
interface BranchChangeRouteHandlerOptions {
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
onRoutesChanged: (workspaceId: string) => void;
logger?: Logger;
}
interface RouteHostnameUpdate {
oldHostname: string;
newHostname: string;
route: ScriptRouteEntry;
}
export function createBranchChangeRouteHandler(
options: BranchChangeRouteHandlerOptions,
): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void {
return (workspaceId, _oldBranch, newBranch) => {
// Only service scripts register routes, so branch renames only touch services.
const routes = options.routeStore.listRoutesForWorkspace(workspaceId);
if (routes.length === 0) {
const changed = options.serviceProxy.replaceWorkspaceBranchRoutes({ workspaceId, newBranch });
if (!changed) {
return;
}
const updates: RouteHostnameUpdate[] = [];
for (const route of routes) {
const newHostname = buildScriptHostname({
projectSlug: route.projectSlug,
branchName: newBranch,
scriptName: route.scriptName,
});
if (newHostname !== route.hostname) {
updates.push({
oldHostname: route.hostname,
newHostname,
route,
});
}
}
if (updates.length === 0) {
return;
}
for (const { oldHostname, newHostname, route } of updates) {
options.routeStore.removeRoute(oldHostname);
options.routeStore.registerRoute({
hostname: newHostname,
port: route.port,
workspaceId: route.workspaceId,
projectSlug: route.projectSlug,
scriptName: route.scriptName,
});
options.logger?.info(
{
oldHostname,
newHostname,
scriptName: route.scriptName,
},
"Updated script route for branch rename",
);
}
options.logger?.info(
{ workspaceId, newBranch },
"Updated service proxy routes for branch rename",
);
options.onRoutesChanged(workspaceId);
};
}

View File

@@ -54,15 +54,22 @@ function buildPayloads(input: {
workspaceId: string;
workspaceDirectory: string;
paseoConfig?: PaseoConfig | null;
routeStore: ScriptRouteStore;
routeStore?: ScriptRouteStore;
serviceProxy?: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
gitMetadata?: { projectSlug: string; currentBranch: string | null };
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}) {
const paseoConfig =
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
return buildWorkspaceScriptPayloads({ ...input, paseoConfig });
const { routeStore, serviceProxy, ...rest } = input;
return buildWorkspaceScriptPayloads({
...rest,
serviceProxy: serviceProxy ?? routeStore ?? new ScriptRouteStore(),
paseoConfig,
});
}
function loadConfig(repoRoot: string): PaseoConfig | null {
@@ -131,9 +138,11 @@ describe("script-status-projection", () => {
{
scriptName: "web",
type: "service",
hostname: "web.repo.localhost",
hostname: "web--repo.localhost",
port: 3000,
proxyUrl: "http://web.repo.localhost:6767",
localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "stopped",
health: null,
exitCode: null,
@@ -162,7 +171,7 @@ describe("script-status-projection", () => {
const payloads = buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
serviceProxy: routeStore,
runtimeStore,
daemonPort: 6767,
gitMetadata: {
@@ -175,9 +184,54 @@ describe("script-status-projection", () => {
{
scriptName: "web",
type: "service",
hostname: "web.feature-from-service.service-provided.localhost",
hostname: "web--feature-from-service--service-provided.localhost",
port: 3000,
proxyUrl: "http://web.feature-from-service.service-provided.localhost:6767",
localProxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
lifecycle: "stopped",
health: null,
exitCode: null,
terminalId: null,
},
]);
} finally {
workspace.cleanup();
}
});
it("projects local and public service URLs while keeping proxyUrl public-first", () => {
const workspaceId = "workspace-public-service";
const workspace = createWorkspaceRepo({
paseoConfig: {
scripts: {
web: { type: "service", command: "npm run web", port: 3000 },
},
},
});
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
try {
expect(
buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
serviceProxyPublicBaseUrl: "https://services.example.com",
gitMetadata: { projectSlug: "repo", currentBranch: "feature/card" },
}),
).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web--feature-card--repo.localhost",
port: 3000,
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
publicProxyUrl: "https://web--feature-card--repo.services.example.com",
proxyUrl: "https://web--feature-card--repo.services.example.com",
lifecycle: "stopped",
health: null,
exitCode: null,
@@ -201,7 +255,7 @@ describe("script-status-projection", () => {
});
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "web.feature-card.repo.localhost",
hostname: "web--feature-card--repo.localhost",
port: 4321,
workspaceId,
projectSlug: "repo",
@@ -231,9 +285,11 @@ describe("script-status-projection", () => {
{
scriptName: "web",
type: "service",
hostname: "web.feature-card.repo.localhost",
hostname: "web--feature-card--repo.localhost",
port: 4321,
proxyUrl: "http://web.feature-card.repo.localhost:6767",
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature-card--repo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
@@ -256,7 +312,7 @@ describe("script-status-projection", () => {
});
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "web.repo.localhost",
hostname: "web--repo.localhost",
port: 4321,
workspaceId,
projectSlug: "repo",
@@ -286,9 +342,11 @@ describe("script-status-projection", () => {
{
scriptName: "web",
type: "service",
hostname: "web.repo.localhost",
hostname: "web--repo.localhost",
port: 4321,
proxyUrl: "http://web.repo.localhost:6767",
localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "running",
health: null,
exitCode: null,
@@ -305,7 +363,7 @@ describe("script-status-projection", () => {
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "docs.repo.localhost",
hostname: "docs--repo.localhost",
port: 3002,
workspaceId,
projectSlug: "repo",
@@ -334,9 +392,11 @@ describe("script-status-projection", () => {
{
scriptName: "docs",
type: "service",
hostname: "docs.repo.localhost",
hostname: "docs--repo.localhost",
port: 3002,
proxyUrl: "http://docs.repo.localhost:6767",
localProxyUrl: "http://docs--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://docs--repo.localhost:6767",
lifecycle: "running",
health: null,
exitCode: null,
@@ -462,7 +522,7 @@ describe("script-status-projection", () => {
});
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: 3001,
workspaceId,
projectSlug: "repo",
@@ -481,7 +541,7 @@ describe("script-status-projection", () => {
const session = { emit: vi.fn() };
const emitUpdate = createScriptStatusEmitter({
sessions: () => [session],
routeStore,
serviceProxy: routeStore,
runtimeStore,
daemonPort: 6767,
resolveWorkspaceDirectory: async (requestedWorkspaceId) =>
@@ -493,7 +553,7 @@ describe("script-status-projection", () => {
emitUpdate(workspaceId, [
{
scriptName: "api",
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: 3001,
health: "healthy",
},
@@ -508,9 +568,11 @@ describe("script-status-projection", () => {
{
scriptName: "api",
type: "service",
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: 3001,
proxyUrl: "http://api.repo.localhost:6767",
localProxyUrl: "http://api--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://api--repo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,

View File

@@ -5,11 +5,13 @@ import type {
WorkspaceScriptPayload,
} from "@getpaseo/protocol/messages";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type {
ServiceProxySubsystem,
ServiceProxyWorkspaceScriptProjection,
} from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
interface SessionEmitter {
@@ -20,9 +22,10 @@ interface BuildWorkspaceScriptPayloadsOptions {
workspaceId: string;
workspaceDirectory: string;
paseoConfig: PaseoConfig | null;
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
gitMetadata?: {
projectSlug: string;
currentBranch: string | null;
@@ -52,13 +55,6 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
return daemonPort;
}
function toServiceProxyUrl(hostname: string, daemonPort: number | null): string | null {
if (daemonPort === null) {
return null;
}
return `http://${hostname}:${daemonPort}`;
}
function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
if (health === "pending" || health === null) {
return null;
@@ -76,43 +72,92 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa
}
type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number];
type RouteEntry = ReturnType<ScriptRouteStore["listRoutesForWorkspace"]>[number];
interface BuildPayloadContext {
projectSlug: string;
branchName: string | null;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
serviceProxy: ServiceProxySubsystem;
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}
function projectWorkspaceServiceState(params: {
workspaceId: string;
scriptName: string;
ctx: BuildPayloadContext;
}): ServiceProxyWorkspaceScriptProjection {
return params.ctx.serviceProxy.projectWorkspaceServiceState({
workspaceId: params.workspaceId,
projectSlug: params.ctx.projectSlug,
branchName: params.ctx.branchName,
scriptName: params.scriptName,
daemonPort: params.ctx.daemonPort,
publicBaseUrl: params.ctx.serviceProxyPublicBaseUrl,
});
}
function buildConfiguredPlainScriptPayload(
scriptName: string,
runtimeEntry: RuntimeEntry | null,
): WorkspaceScriptPayload {
return {
scriptName,
type: "script",
hostname: scriptName,
port: null,
proxyUrl: null,
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: null,
exitCode: runtimeEntry?.exitCode ?? null,
terminalId: runtimeEntry?.terminalId ?? null,
};
}
function buildConfiguredScriptPayload(
scriptName: string,
config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? V : never,
runtimeEntry: RuntimeEntry | null,
routeEntry: RouteEntry | null,
serviceState: ServiceProxyWorkspaceScriptProjection | null,
ctx: BuildPayloadContext,
): WorkspaceScriptPayload {
const configIsService = isServiceScript(config);
const type = configIsService ? "service" : "script";
const configuredPort = configIsService ? (config.port ?? null) : null;
const hostname =
type === "service"
? (routeEntry?.hostname ??
buildScriptHostname({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
}))
: scriptName;
if (!configIsService) {
return buildConfiguredPlainScriptPayload(scriptName, runtimeEntry);
}
const type = "service";
const configuredPort = config.port ?? null;
const hostname = (
serviceState ??
ctx.serviceProxy.projectWorkspaceService({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
})
).hostname;
const urls =
serviceState ??
ctx.serviceProxy.projectUrls({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
});
return {
scriptName,
type,
hostname,
port: type === "service" ? (routeEntry?.port ?? configuredPort) : null,
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null,
port: serviceState?.port ?? configuredPort,
localProxyUrl: urls.localProxyUrl,
publicProxyUrl: urls.publicProxyUrl,
proxyUrl: urls.proxyUrl,
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
health: toWireHealth(ctx.resolveHealth?.(hostname) ?? null),
exitCode: runtimeEntry?.exitCode ?? null,
terminalId: runtimeEntry?.terminalId ?? null,
};
@@ -120,28 +165,47 @@ function buildConfiguredScriptPayload(
function buildOrphanRuntimePayload(
runtimeEntry: RuntimeEntry,
routeEntry: RouteEntry | null,
serviceState: ServiceProxyWorkspaceScriptProjection | null,
ctx: BuildPayloadContext,
): WorkspaceScriptPayload {
const type = runtimeEntry.type;
const hostname =
type === "service"
? (routeEntry?.hostname ??
buildScriptHostname({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
}))
? (
serviceState ??
ctx.serviceProxy.projectWorkspaceService({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
})
).hostname
: runtimeEntry.scriptName;
const urls =
serviceState ??
ctx.serviceProxy.projectUrls({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
});
return {
scriptName: runtimeEntry.scriptName,
type,
hostname,
port: type === "service" ? (routeEntry?.port ?? null) : null,
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null,
port: type === "service" ? (serviceState?.port ?? null) : null,
...(type === "service"
? { localProxyUrl: urls.localProxyUrl, publicProxyUrl: urls.publicProxyUrl }
: {}),
proxyUrl: type === "service" ? urls.proxyUrl : null,
lifecycle: runtimeEntry.lifecycle,
health:
type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
type === "service" && serviceState?.port !== null
? toWireHealth(ctx.resolveHealth?.(hostname) ?? null)
: null,
exitCode: runtimeEntry.exitCode,
terminalId: runtimeEntry.terminalId,
};
@@ -160,16 +224,12 @@ export function buildWorkspaceScriptPayloads(
.listForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const),
);
const routesByScriptName = new Map(
options.routeStore
.listRoutesForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const),
);
const ctx: BuildPayloadContext = {
projectSlug,
branchName,
daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
serviceProxy: options.serviceProxy,
resolveHealth: options.resolveHealth,
};
@@ -177,16 +237,23 @@ export function buildWorkspaceScriptPayloads(
for (const [scriptName, config] of scriptConfigs.entries()) {
const runtimeEntry = runtimeEntries.get(scriptName) ?? null;
const routeEntry = routesByScriptName.get(scriptName) ?? null;
payloads.push(buildConfiguredScriptPayload(scriptName, config, runtimeEntry, routeEntry, ctx));
const serviceState = isServiceScript(config)
? projectWorkspaceServiceState({ workspaceId, scriptName, ctx })
: null;
payloads.push(
buildConfiguredScriptPayload(scriptName, config, runtimeEntry, serviceState, ctx),
);
}
for (const runtimeEntry of runtimeEntries.values()) {
if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") {
continue;
}
const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null;
payloads.push(buildOrphanRuntimePayload(runtimeEntry, routeEntry, ctx));
const serviceState =
runtimeEntry.type === "service"
? projectWorkspaceServiceState({ workspaceId, scriptName: runtimeEntry.scriptName, ctx })
: null;
payloads.push(buildOrphanRuntimePayload(runtimeEntry, serviceState, ctx));
}
return sortPayloads(payloads);
@@ -207,16 +274,18 @@ function buildScriptStatusUpdateMessage(params: {
export function createScriptStatusEmitter({
sessions,
routeStore,
serviceProxy,
runtimeStore,
daemonPort,
serviceProxyPublicBaseUrl,
resolveWorkspaceDirectory,
logger,
}: {
sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null);
serviceProxyPublicBaseUrl?: string | null;
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
logger: Logger;
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
@@ -236,9 +305,10 @@ export function createScriptStatusEmitter({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
routeStore,
serviceProxy,
runtimeStore,
daemonPort: resolvedDaemonPort,
serviceProxyPublicBaseUrl,
resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null,
});

View File

@@ -0,0 +1,295 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import http from "node:http";
import express from "express";
import { describe, expect, it } from "vitest";
import pino from "pino";
import {
buildLocalServiceHostname,
buildPublicServiceHostname,
buildServiceProxyLabel,
createServiceProxySubsystem,
findFreePort,
ServiceProxyRouteRegistry,
} from "./service-proxy.js";
const logger = pino({ level: "silent" });
function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[] {
const entries: string[] = [];
for (const name of readdirSync(dir)) {
const fullPath = path.join(dir, name);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
entries.push(...readServerSourceFiles(fullPath));
} else if (fullPath.endsWith(".ts") && !fullPath.endsWith(".test.ts")) {
entries.push(fullPath);
}
}
return entries;
}
function httpGet(port: number, host: string, requestPath = "/api/health") {
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
(res) => {
let body = "";
res.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
},
);
req.on("error", reject);
});
}
describe("service proxy subsystem shape", () => {
it("keeps production imports behind the service-proxy entrypoint", () => {
const offenders: string[] = [];
for (const filePath of readServerSourceFiles()) {
if (filePath.endsWith("service-proxy.ts") || filePath.endsWith("script-proxy.ts")) {
continue;
}
const source = readFileSync(filePath, "utf8");
for (const needle of ["./script-proxy.js", "../utils/script-hostname.js"]) {
if (source.includes(needle)) {
offenders.push(`${path.relative(import.meta.dirname, filePath)} imports ${needle}`);
}
}
}
expect(offenders).toEqual([]);
});
it("classifies the configured public namespace before any route exists", async () => {
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: "https://services.example.com",
});
const port = await findFreePort();
const app = express();
app.use(serviceProxy.middleware());
app.use((_req, res) => {
res.status(200).send("daemon-api");
});
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
try {
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
status: 404,
body: "404 Not Found",
});
await expect(httpGet(port, `daemon.localhost:${port}`)).resolves.toEqual({
status: 200,
body: "daemon-api",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("keeps configured public namespace classified after the last public route is removed", async () => {
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: "https://services.example.com",
});
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
publicBaseUrl: "https://services.example.com",
});
serviceProxy.removeWorkspaceService({ workspaceId: "workspace-a", scriptName: "api" });
const port = await findFreePort();
const app = express();
app.use(serviceProxy.middleware());
app.use((_req, res) => {
res.status(200).send("daemon-api");
});
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
try {
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
status: 404,
body: "404 Not Found",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("uses the same hash-truncated service label for local and public hostnames", () => {
const input = {
projectSlug: "project-".repeat(10),
branchName: "branch-".repeat(10),
scriptName: "script-".repeat(10),
};
const label = buildServiceProxyLabel(input);
expect(label.length).toBeLessThanOrEqual(63);
expect(label.endsWith("-")).toBe(false);
expect(buildLocalServiceHostname(input)).toBe(`${label}.localhost`);
expect(
buildPublicServiceHostname({ ...input, publicBaseUrl: "https://services.example.com" }),
).toBe(`${label}.services.example.com`);
expect(buildServiceProxyLabel(input)).toBe(label);
expect(
buildServiceProxyLabel({ ...input, scriptName: `different-${input.scriptName}` }),
).not.toBe(label);
});
it("gives long labels with the same prefix different hash suffixes", () => {
const sharedPrefix = "service-".repeat(12);
const first = buildServiceProxyLabel({
projectSlug: "repo",
branchName: "feature/shared-prefix",
scriptName: `${sharedPrefix}alpha`,
});
const second = buildServiceProxyLabel({
projectSlug: "repo",
branchName: "feature/shared-prefix",
scriptName: `${sharedPrefix}beta`,
});
expect(first).not.toBe(second);
expect(first.slice(0, -10)).toBe(second.slice(0, -10));
expect(first.split("--").at(-1)).not.toBe(second.split("--").at(-1));
});
it("rejects cross-service collisions without deleting the existing route", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
});
expect(() =>
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-b",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
workspaceId: "workspace-a",
port: 3000,
});
});
it("rejects public alias collisions without deleting the existing route", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
publicBaseUrl: "https://services.example.com",
});
expect(() =>
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-b",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
publicBaseUrl: "https://services.example.com",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getHealthTargetForHostname("api--repo.services.example.com")).toMatchObject(
{
workspaceId: "workspace-a",
port: 3000,
},
);
});
it("rejects public alias collisions even when canonical hostnames differ", () => {
const serviceProxy = new ServiceProxyRouteRegistry();
serviceProxy.registerRoute({
hostname: "api--repo-a.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3000,
workspaceId: "workspace-a",
projectSlug: "repo-a",
scriptName: "api",
});
expect(() =>
serviceProxy.registerRoute({
hostname: "api--repo-b.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 4000,
workspaceId: "workspace-b",
projectSlug: "repo-b",
scriptName: "api",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getRouteEntry("api--repo-a.localhost")).toMatchObject({ port: 3000 });
expect(serviceProxy.getRouteEntry("api--repo-b.localhost")).toBeNull();
});
it("rejects canonical-to-public-alias collisions", () => {
const serviceProxy = new ServiceProxyRouteRegistry();
serviceProxy.registerRoute({
hostname: "api--repo.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3000,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
expect(() =>
serviceProxy.registerRoute({
hostname: "api.services.example.com",
port: 4000,
workspaceId: "workspace-b",
projectSlug: "other",
scriptName: "api",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getRouteEntry("api--repo.localhost")).toMatchObject({ port: 3000 });
expect(serviceProxy.getRouteEntry("api.services.example.com")).toMatchObject({ port: 3000 });
});
it("allows same workspace/script replacement", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
});
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
});
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
port: 4000,
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -229,7 +229,7 @@ interface SessionForTestOptions {
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
terminalManager?: SessionOptions["terminalManager"];
scriptRouteStore?: SessionOptions["scriptRouteStore"];
serviceProxy?: SessionOptions["serviceProxy"];
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null;
@@ -311,7 +311,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
terminalManager: options.terminalManager ?? null,
providerSnapshotManager:
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
scriptRouteStore: options.scriptRouteStore,
serviceProxy: options.serviceProxy,
scriptRuntimeStore: options.scriptRuntimeStore,
getDaemonTcpPort: options.getDaemonTcpPort,
getDaemonTcpHost: options.getDaemonTcpHost,
@@ -3549,7 +3549,7 @@ describe("session workspace script handling", () => {
workspaceGitService,
workspaceRegistry,
terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) },
scriptRouteStore: { listRoutesForWorkspace: vi.fn(() => []) },
serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) },
scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) },
getDaemonTcpPort: () => 6767,
getDaemonTcpHost: () => "127.0.0.1",

View File

@@ -192,7 +192,7 @@ import {
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import {
checkoutResolvedBranch,
type CheckoutExistingBranchResult,
@@ -569,6 +569,7 @@ export interface SessionOptions {
downloadTokenStore: DownloadTokenStore;
pushTokenStore: PushTokenStore;
paseoHome: string;
worktreesRoot?: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
@@ -587,7 +588,7 @@ export interface SessionOptions {
tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
scriptRouteStore?: ScriptRouteStore;
serviceProxy?: ServiceProxySubsystem;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
onBranchChanged?: (
@@ -597,6 +598,7 @@ export interface SessionOptions {
) => void;
getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null;
serviceProxyPublicBaseUrl?: string | null;
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
voice?: {
turnDetection?: Resolvable<TurnDetectionProvider | null>;
@@ -742,6 +744,7 @@ export class Session {
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
// State machine
private abortController: AbortController;
@@ -800,7 +803,7 @@ export class Session {
private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly serviceProxy: ServiceProxySubsystem | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly onBranchChanged?: (
workspaceId: string,
@@ -809,6 +812,7 @@ export class Session {
) => void;
private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly getDaemonTcpHost: (() => string | null) | null;
private readonly serviceProxyPublicBaseUrl: string | null;
private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
private readonly terminalController: TerminalSessionController;
private inflightRequests = 0;
@@ -859,6 +863,7 @@ export class Session {
downloadTokenStore,
pushTokenStore,
paseoHome,
worktreesRoot,
agentManager,
agentStorage,
projectRegistry,
@@ -876,12 +881,13 @@ export class Session {
tts,
terminalManager,
providerSnapshotManager,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
workspaceSetupSnapshots,
onBranchChanged,
getDaemonTcpPort,
getDaemonTcpHost,
serviceProxyPublicBaseUrl,
resolveScriptHealth,
voice,
voiceBridge,
@@ -900,6 +906,7 @@ export class Session {
this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome;
this.worktreesRoot = worktreesRoot;
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
@@ -930,6 +937,7 @@ export class Session {
});
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
github: this.github,
@@ -958,12 +966,13 @@ export class Session {
logger: this.sessionLogger,
});
this.providerSnapshotManager = providerSnapshotManager;
this.scriptRouteStore = scriptRouteStore ?? null;
this.serviceProxy = serviceProxy ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null;
this.sttLanguage = sttLanguage ?? "en";
this.subscribeToOptionalManagers();
@@ -3111,6 +3120,7 @@ export class Session {
agentStorage: this.agentStorage,
logger: this.sessionLogger,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
@@ -3491,6 +3501,7 @@ export class Session {
return buildWorktreeAgentSessionConfig(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
createPaseoWorktree: (input, serviceOptions) =>
@@ -5230,7 +5241,7 @@ export class Session {
baseRef,
mode: msg.strategy === "squash" ? "squash" : "merge",
},
{ paseoHome: this.paseoHome },
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
);
await Promise.all([
this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
@@ -5711,6 +5722,7 @@ export class Session {
return handleWorktreeArchiveRequest(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
github: this.github,
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
@@ -6283,14 +6295,15 @@ export class Session {
activityAt: null,
diffStat,
scripts:
this.scriptRouteStore && this.scriptRuntimeStore
this.serviceProxy && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
routeStore: this.scriptRouteStore,
serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd),
resolveHealth: this.resolveScriptHealth ?? undefined,
})
@@ -7111,16 +7124,17 @@ export class Session {
workspaceId: string,
workspaceDirectory: string,
): WorkspaceDescriptorPayload["scripts"] {
if (!this.scriptRouteStore || !this.scriptRuntimeStore) {
if (!this.serviceProxy || !this.scriptRuntimeStore) {
return [];
}
return buildWorkspaceScriptPayloads({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
routeStore: this.scriptRouteStore,
serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory),
resolveHealth: this.resolveScriptHealth ?? undefined,
});
@@ -7168,7 +7182,7 @@ export class Session {
request: StartWorkspaceScriptRequest,
): Promise<void> {
try {
if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) {
if (!this.terminalManager || !this.serviceProxy || !this.scriptRuntimeStore) {
throw new Error("Workspace scripts are not available on this daemon");
}
@@ -7186,7 +7200,8 @@ export class Session {
scriptName: request.scriptName,
daemonPort: this.getDaemonTcpPort?.() ?? null,
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
routeStore: this.scriptRouteStore,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager,
logger: this.sessionLogger,
@@ -7298,6 +7313,7 @@ export class Session {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
emit: (message) => this.emit(message),
sessionLogger: this.sessionLogger,
@@ -7317,6 +7333,7 @@ export class Session {
return createWorktreeWorkflow(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
createPaseoWorktree: (workflowInput, serviceOptions) =>
this.createPaseoWorktree(workflowInput, serviceOptions),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
@@ -7331,10 +7348,11 @@ export class Session {
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
scriptRouteStore: this.scriptRouteStore,
serviceProxy: this.serviceProxy,
scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
onScriptsChanged: (workspaceId, workspaceDirectory) => {
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
},

View File

@@ -2,10 +2,11 @@ import { describe, expect, test, vi } from "vitest";
import path from "node:path";
import type pino from "pino";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import { createTestLogger } from "../test-utils/test-logger.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type {
WorkspaceGitListener,
@@ -102,7 +103,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
oldBranch: string | null,
newBranch: string | null,
) => void;
scriptRouteStore?: ScriptRouteStore;
serviceProxy?: ServiceProxySubsystem;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
}): {
session: Session;
@@ -234,7 +235,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
tts: null,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
terminalManager: null,
scriptRouteStore: options?.scriptRouteStore,
serviceProxy: options?.serviceProxy,
scriptRuntimeStore: options?.scriptRuntimeStore,
onBranchChanged: options?.onBranchChanged,
getDaemonTcpPort: () => 6767,
@@ -425,12 +426,12 @@ describe("workspace git watch targets", () => {
});
test("updates running service script URLs when the git branch changes", async () => {
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "app.old-branch.paseo.localhost",
const serviceProxy = createServiceProxySubsystem({ logger: createTestLogger() });
serviceProxy.registerWorkspaceService({
port: 4321,
workspaceId: "ws-10",
projectSlug: "paseo",
branchName: "old-branch",
scriptName: "app",
});
const runtimeStore = new WorkspaceScriptRuntimeStore();
@@ -444,12 +445,12 @@ describe("workspace git watch targets", () => {
});
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy,
onRoutesChanged: vi.fn(),
});
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
{
scriptRouteStore: routeStore,
serviceProxy,
scriptRuntimeStore: runtimeStore,
onBranchChanged: handleBranchChange,
},
@@ -474,18 +475,19 @@ describe("workspace git watch targets", () => {
}),
);
expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([
expect(serviceProxy.getWorkspaceHealthTargets("ws-10")).toEqual([
expect.objectContaining({
hostname: "app.new-branch.paseo.localhost",
projectSlug: "paseo",
hostname: "app--new-branch--paseo.localhost",
scriptName: "app",
}),
]);
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
expect.objectContaining({
scriptName: "app",
hostname: "app.new-branch.paseo.localhost",
proxyUrl: "http://app.new-branch.paseo.localhost:6767",
hostname: "app--new-branch--paseo.localhost",
localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
}),
]);

View File

@@ -33,6 +33,7 @@ interface TestPaseoDaemonOptions {
dictationFinalTimeoutMs?: number;
auth?: PaseoDaemonConfig["auth"];
pushNotificationSender?: PushNotificationSender;
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
}
export interface TestPaseoDaemon {
@@ -161,6 +162,7 @@ async function prepareTestDaemonConfig(
appBaseUrl: "https://app.paseo.sh",
auth: options.auth,
pushNotificationSender: options.pushNotificationSender,
serviceProxy: options.serviceProxy,
openai: options.openai,
speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null,

View File

@@ -92,10 +92,10 @@ export function asWorkspaceGitService(stub: {
return createStub<SessionOptions["workspaceGitService"]>(stub);
}
export function asScriptRouteStore(stub: {
[K in keyof SessionOptions["scriptRouteStore"]]?: unknown;
}): SessionOptions["scriptRouteStore"] {
return createStub<SessionOptions["scriptRouteStore"]>(stub);
export function asServiceProxy(stub: {
[K in keyof SessionOptions["serviceProxy"]]?: unknown;
}): SessionOptions["serviceProxy"] {
return createStub<SessionOptions["serviceProxy"]>(stub);
}
export function asWorkspaceScriptRuntimeStore(stub: {

View File

@@ -35,7 +35,7 @@ import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.
import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
@@ -350,16 +350,18 @@ export class VoiceAssistantWebSocketServer {
private readonly workspaceGitService: WorkspaceGitService;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private readonly pushNotificationSender: PushNotificationSender;
private readonly mcpBaseUrl: string | null;
private speech!: SpeechService | null;
private terminalManager!: TerminalManager | null;
private scriptRouteStore!: ScriptRouteStore | null;
private serviceProxy!: ServiceProxySubsystem | null;
private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null;
private getDaemonTcpPort!: (() => number | null) | null;
private getDaemonTcpHost!: (() => string | null) | null;
private serviceProxyPublicBaseUrl!: string | null;
private resolveScriptHealth!: ((hostname: string) => ScriptHealthState | null) | null;
private dictation!: {
finalTimeoutMs?: number;
@@ -403,7 +405,7 @@ export class VoiceAssistantWebSocketServer {
loopService?: LoopService,
scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager,
scriptRouteStore?: ScriptRouteStore | null,
serviceProxy?: ServiceProxySubsystem | null,
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null,
onBranchChanged?: (
workspaceId: string,
@@ -419,6 +421,7 @@ export class VoiceAssistantWebSocketServer {
providerSnapshotManager?: ProviderSnapshotManager,
daemonRuntimeConfig?: {
listen: string | null;
worktreesRoot?: string;
relay: {
enabled: boolean;
endpoint: string;
@@ -427,6 +430,7 @@ export class VoiceAssistantWebSocketServer {
publicUseTls: boolean;
};
},
serviceProxyPublicBaseUrl?: string | null,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -453,6 +457,7 @@ export class VoiceAssistantWebSocketServer {
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
this.daemonConfigStore = daemonConfigStore;
this.mcpBaseUrl = mcpBaseUrl;
this.assignOptionalServices({
@@ -460,11 +465,12 @@ export class VoiceAssistantWebSocketServer {
terminalManager,
dictation,
onLifecycleIntent,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
onBranchChanged,
getDaemonTcpPort,
getDaemonTcpHost,
serviceProxyPublicBaseUrl,
resolveScriptHealth,
});
if (!providerSnapshotManager) {
@@ -508,24 +514,26 @@ export class VoiceAssistantWebSocketServer {
terminalManager: TerminalManager | null | undefined;
dictation: { finalTimeoutMs?: number } | undefined;
onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined;
scriptRouteStore: ScriptRouteStore | null | undefined;
serviceProxy: ServiceProxySubsystem | null | undefined;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined;
onBranchChanged:
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
| undefined;
getDaemonTcpPort: (() => number | null) | undefined;
getDaemonTcpHost: (() => string | null) | undefined;
serviceProxyPublicBaseUrl: string | null | undefined;
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | undefined;
}): void {
this.speech = params.speech ?? null;
this.terminalManager = params.terminalManager ?? null;
this.dictation = params.dictation ?? null;
this.onLifecycleIntent = params.onLifecycleIntent ?? null;
this.scriptRouteStore = params.scriptRouteStore ?? null;
this.serviceProxy = params.serviceProxy ?? null;
this.scriptRuntimeStore = params.scriptRuntimeStore ?? null;
this.onBranchChanged = params.onBranchChanged ?? null;
this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
this.getDaemonTcpHost = params.getDaemonTcpHost ?? null;
this.serviceProxyPublicBaseUrl = params.serviceProxyPublicBaseUrl ?? null;
this.resolveScriptHealth = params.resolveScriptHealth ?? null;
}
@@ -858,6 +866,7 @@ export class VoiceAssistantWebSocketServer {
downloadTokenStore: this.downloadTokenStore,
pushTokenStore: this.pushTokenStore,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
projectRegistry: this.projectRegistry,
@@ -875,12 +884,13 @@ export class VoiceAssistantWebSocketServer {
tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager,
scriptRouteStore: this.scriptRouteStore ?? undefined,
serviceProxy: this.serviceProxy ?? undefined,
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
onBranchChanged: this.onBranchChanged ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
voice: {
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,

View File

@@ -0,0 +1,153 @@
import { describe, expect, test } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { createTestLogger } from "../test-utils/test-logger.js";
import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js";
import { WorkspaceDirectory } from "./workspace-directory.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
const NOW = "2026-03-01T12:00:00.000Z";
class WorkspaceStatus {
private readonly project: PersistedProjectRecord = {
projectId: "project-1",
rootPath: "/workspace/project",
kind: "git",
displayName: "project",
customName: null,
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
private readonly workspace: PersistedWorkspaceRecord = {
workspaceId: "workspace-1",
projectId: this.project.projectId,
cwd: this.project.rootPath,
kind: "local_checkout",
displayName: "main",
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
private readonly agents: AgentSnapshotPayload[] = [];
private readonly directory = new WorkspaceDirectory({
logger: createTestLogger(),
projectRegistry: { list: async () => [this.project] },
workspaceRegistry: { list: async () => [this.workspace] },
listAgentPayloads: async () => this.agents,
isProviderVisibleToClient: () => true,
buildWorkspaceDescriptor: async ({ workspace }) => ({
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: "project",
projectCustomName: null,
projectRootPath: this.project.rootPath,
workspaceDirectory: workspace.cwd,
projectKind: "git",
workspaceKind: workspace.kind,
name: workspace.displayName,
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
gitRuntime: null,
githubRuntime: null,
}),
});
hasRootAgent(input: AgentState): void {
this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd }));
}
hasDelegatedAgent(input: AgentState): void {
this.agents.push(
createAgent({
...input,
cwd: this.workspace.cwd,
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
}),
);
}
async workspaceStatus(): Promise<WorkspaceDescriptorPayload["status"]> {
const entries = await this.directory.listFetchEntries({
type: "fetch_workspaces_request",
requestId: "workspace-status",
});
return entries.entries[0]?.status ?? "done";
}
}
interface AgentState {
id: string;
status: AgentSnapshotPayload["status"];
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: AgentSnapshotPayload["attentionReason"];
}
function createAgent(input: AgentState & { cwd: string; labels?: Record<string, string> }) {
const pendingPermissionCount = input.pendingPermissionCount ?? 0;
return {
id: input.id,
provider: "codex",
cwd: input.cwd,
model: null,
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: NOW,
updatedAt: NOW,
lastUserMessageAt: null,
status: input.status,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: Array.from({ length: pendingPermissionCount }, (_, index) => ({
id: `permission-${input.id}-${index}`,
provider: "codex",
name: "tool",
kind: "tool" as const,
})),
persistence: null,
runtimeInfo: {
provider: "codex",
sessionId: null,
},
title: null,
labels: input.labels ?? {},
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: null,
archivedAt: null,
} satisfies AgentSnapshotPayload;
}
describe("WorkspaceDirectory", () => {
test("uses root agent activity, not delegated child activity, for workspace status", async () => {
const workspace = new WorkspaceStatus();
workspace.hasRootAgent({ id: "root-agent", status: "running" });
workspace.hasDelegatedAgent({
id: "child-needs-input",
status: "idle",
pendingPermissionCount: 1,
});
workspace.hasDelegatedAgent({
id: "child-error",
status: "error",
requiresAttention: true,
attentionReason: "error",
});
await expect(workspace.workspaceStatus()).resolves.toBe("running");
});
});

View File

@@ -11,6 +11,7 @@ import {
deriveAgentStateBucket,
getWorkspaceStateBucketPriority,
} from "@getpaseo/protocol/agent-state-bucket";
import { isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
import { SortablePager } from "./pagination/sortable-pager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
@@ -187,6 +188,9 @@ export class WorkspaceDirectory {
if (!this.deps.isProviderVisibleToClient(agent.provider)) {
continue;
}
if (isDelegatedAgent(agent)) {
continue;
}
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd));
if (workspaceId === undefined) {

View File

@@ -879,6 +879,53 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github.dispose?.();
});
test("GitHub self-heal polling uses the fork PR head branch instead of the owner-prefixed local branch", async () => {
const retainCurrentPullRequestStatusPoll = vi.fn(() => ({ unsubscribe: vi.fn() }));
const github = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll,
};
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
createCheckoutFacts(cwd, {
currentBranch: "fork-owner/open-button-targets-active-file",
branchRemoteName: "paseo-pr-1285",
branchMergeRef: "refs/heads/open-button-targets-active-file",
trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file",
pullRequestLookupTarget: {
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
},
}),
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "fork-owner/open-button-targets-active-file",
remoteUrl: "git@github.com:getpaseo/paseo.git",
}),
);
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutStatus,
github,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
await vi.waitFor(() => {
expect(retainCurrentPullRequestStatusPoll).toHaveBeenCalledWith(
expect.objectContaining({
cwd: REPO_CWD,
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
}),
);
});
subscription.unsubscribe();
service.dispose();
});
test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => {
let nowMs = 0;
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];

View File

@@ -259,6 +259,7 @@ interface WorkspaceGitServiceDependencies {
interface WorkspaceGitServiceOptions {
logger: pino.Logger;
paseoHome: string;
worktreesRoot?: string;
deps?: Partial<WorkspaceGitServiceDependencies>;
}
@@ -269,7 +270,7 @@ interface WorkspaceGitTarget {
debounceTimer: NodeJS.Timeout | null;
selfHealTimer: NodeJS.Timeout | null;
githubPollSubscription: { unsubscribe: () => void } | null;
githubPollHeadRef: string | null;
githubPollKey: string | null;
refreshState: WorkspaceGitRefreshState;
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
latestGitLoadedAtMs: number | null;
@@ -316,6 +317,11 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
inFlight: Promise<T> | null;
}
interface WorkspaceGitHubPollTarget {
headRef: string;
headRepositoryOwner?: string;
}
function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies {
return {
watch,
@@ -347,6 +353,7 @@ function resolveWorkspaceGitServiceDeps(
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly deps: WorkspaceGitServiceDependencies;
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
@@ -385,6 +392,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
constructor(options: WorkspaceGitServiceOptions) {
this.logger = options.logger.child({ module: "workspace-git-service" });
this.paseoHome = options.paseoHome;
this.worktreesRoot = options.worktreesRoot;
this.deps = resolveWorkspaceGitServiceDeps(options.deps);
}
@@ -438,6 +446,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
try {
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
logger: this.logger,
});
if (!status.isGit) {
@@ -484,7 +493,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () =>
this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { paseoHome: this.paseoHome }),
this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
}),
);
}
@@ -581,6 +593,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.deps.listPaseoWorktrees({
cwd: repoRoot,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
}),
);
}
@@ -788,7 +801,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
debounceTimer: null,
selfHealTimer: null,
githubPollSubscription: null,
githubPollHeadRef: null,
githubPollKey: null,
refreshState: { status: "idle" },
latestGit: null,
latestGitLoadedAtMs: null,
@@ -1148,23 +1161,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
const headRef = git.currentBranch;
const pollTarget = this.resolveGitHubPollTarget(target);
const remoteUrl = git.remoteUrl;
const hasGitHubRemote =
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
target.cachedGitHubRemote?.remoteUrl === remoteUrl &&
target.cachedGitHubRemote.identity !== null;
if (!headRef || !hasGitHubRemote) {
if (!pollTarget || remoteUrl === null || !hasGitHubRemote) {
this.stopGitHubPollForTarget(target);
return;
}
if (target.githubPollHeadRef === headRef && target.githubPollSubscription) {
const pollKey = buildWorkspaceGitHubPollKey(remoteUrl, pollTarget);
if (target.githubPollKey === pollKey && target.githubPollSubscription) {
return;
}
this.stopGitHubPollForTarget(target);
target.githubPollHeadRef = headRef;
target.githubPollKey = pollKey;
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
cwd: target.cwd,
headRef,
headRef: pollTarget.headRef,
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
@@ -1175,17 +1193,40 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
},
onError: (error) => {
this.logger.warn(
{ err: error, cwd: target.cwd, headRef, reason: "self-heal-github" },
{
err: error,
cwd: target.cwd,
headRef: pollTarget.headRef,
headRepositoryOwner: pollTarget.headRepositoryOwner,
reason: "self-heal-github",
},
"Failed to run GitHub self-heal refresh",
);
},
});
}
private resolveGitHubPollTarget(target: WorkspaceGitTarget): WorkspaceGitHubPollTarget | null {
const git = target.latestGit;
if (!git?.currentBranch) {
return null;
}
const lookupTarget =
target.latestFacts?.isGit && target.latestFacts.currentBranch === git.currentBranch
? target.latestFacts.pullRequestLookupTarget
: null;
if (lookupTarget) {
return lookupTarget;
}
return { headRef: git.currentBranch };
}
private stopGitHubPollForTarget(target: WorkspaceGitTarget): void {
target.githubPollSubscription?.unsubscribe();
target.githubPollSubscription = null;
target.githubPollHeadRef = null;
target.githubPollKey = null;
}
private addWorkingTreeWatcher(
@@ -1566,7 +1607,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const cwd = target.cwd;
const previousGitHubPollKey = this.getGitHubPollKey(target);
const baseContext: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const baseContext: CheckoutContext = {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
logger: this.logger,
};
const facts = await this.loadCheckoutFacts(target, {
...baseContext,
allowRecent: !request.force,
@@ -1657,7 +1702,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return null;
}
return JSON.stringify([git.remoteUrl, git.currentBranch]);
const pollTarget = this.resolveGitHubPollTarget(target);
if (!pollTarget) {
return null;
}
return buildWorkspaceGitHubPollKey(git.remoteUrl, pollTarget);
}
private rememberGitHubSnapshot(
@@ -1964,6 +2014,10 @@ function buildGitHubSnapshotFromStatus(
};
}
function buildWorkspaceGitHubPollKey(remoteUrl: string, target: WorkspaceGitHubPollTarget): string {
return JSON.stringify([remoteUrl, target.headRef, target.headRepositoryOwner ?? null]);
}
async function runGitFetch(cwd: string): Promise<void> {
await runGitCommand(["fetch", "origin", "--prune"], {
cwd,

View File

@@ -65,9 +65,9 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({
HOST: "127.0.0.1",
PASEO_PORT: "5173",
PASEO_URL: "http://daemon.paseo.localhost:6767",
PASEO_URL: "http://daemon--paseo.localhost:6767",
PASEO_SERVICE_DAEMON_PORT: "5173",
PASEO_SERVICE_DAEMON_URL: "http://daemon.paseo.localhost:6767",
PASEO_SERVICE_DAEMON_URL: "http://daemon--paseo.localhost:6767",
});
});
@@ -84,9 +84,9 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({
HOST: "127.0.0.1",
PASEO_PORT: "5173",
PASEO_URL: "http://daemon.feature-x.paseo.localhost:6767",
PASEO_URL: "http://daemon--feature-x--paseo.localhost:6767",
PASEO_SERVICE_DAEMON_PORT: "5173",
PASEO_SERVICE_DAEMON_URL: "http://daemon.feature-x.paseo.localhost:6767",
PASEO_SERVICE_DAEMON_URL: "http://daemon--feature-x--paseo.localhost:6767",
});
});
@@ -137,11 +137,32 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({
HOST: "127.0.0.1",
PASEO_PORT: "5173",
PASEO_URL: "http://web.feature-x.paseo.localhost:6767",
PASEO_URL: "http://web--feature-x--paseo.localhost:6767",
PASEO_SERVICE_API_PORT: "4000",
PASEO_SERVICE_API_URL: "http://api.feature-x.paseo.localhost:6767",
PASEO_SERVICE_API_URL: "http://api--feature-x--paseo.localhost:6767",
PASEO_SERVICE_WEB_PORT: "5173",
PASEO_SERVICE_WEB_URL: "http://web.feature-x.paseo.localhost:6767",
PASEO_SERVICE_WEB_URL: "http://web--feature-x--paseo.localhost:6767",
});
});
it("uses public service URLs when a public base URL is configured", () => {
expect(
buildWorkspaceServiceEnv({
scriptName: "web",
projectSlug: "paseo",
branchName: "feature-x",
daemonPort: 6767,
daemonListenHost: null,
serviceProxyPublicBaseUrl: "https://services.example.com",
peers: [
{ scriptName: "api", port: 4000 },
{ scriptName: "web", port: 5173 },
],
}),
).toMatchObject({
PASEO_URL: "https://web--feature-x--paseo.services.example.com",
PASEO_SERVICE_API_URL: "https://api--feature-x--paseo.services.example.com",
PASEO_SERVICE_WEB_URL: "https://web--feature-x--paseo.services.example.com",
});
});

View File

@@ -1,4 +1,4 @@
import { buildScriptHostname } from "../utils/script-hostname.js";
import { projectServiceProxyUrls } from "./service-proxy.js";
export interface WorkspaceServicePeer {
scriptName: string;
@@ -11,6 +11,7 @@ export interface BuildWorkspaceServiceEnvOptions {
branchName: string | null;
daemonPort: number | null | undefined;
daemonListenHost: string | null | undefined;
serviceProxyPublicBaseUrl?: string | null;
peers: readonly WorkspaceServicePeer[];
}
@@ -37,26 +38,30 @@ export function buildWorkspaceServiceEnv(
PASEO_PORT: String(selfPeer.port),
};
if (options.daemonPort !== null && options.daemonPort !== undefined) {
env.PASEO_URL = buildServiceProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
daemonPort: options.daemonPort,
});
const selfProxyUrl = buildServiceProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
});
if (selfProxyUrl) {
env.PASEO_URL = selfProxyUrl;
}
for (const peer of options.peers) {
const envName = normalizeServiceEnvName(peer.scriptName);
env[`PASEO_SERVICE_${envName}_PORT`] = String(peer.port);
if (options.daemonPort !== null && options.daemonPort !== undefined) {
env[`PASEO_SERVICE_${envName}_URL`] = buildServiceProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: peer.scriptName,
daemonPort: options.daemonPort,
});
const peerProxyUrl = buildServiceProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: peer.scriptName,
daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
});
if (peerProxyUrl) {
env[`PASEO_SERVICE_${envName}_URL`] = peerProxyUrl;
}
}
@@ -71,16 +76,18 @@ interface BuildServiceProxyUrlOptions {
projectSlug: string;
branchName: string | null;
scriptName: string;
daemonPort: number;
daemonPort: number | null | undefined;
serviceProxyPublicBaseUrl?: string | null;
}
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string {
const hostname = buildScriptHostname({
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null {
return projectServiceProxyUrls({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
});
return `http://${hostname}:${options.daemonPort}`;
daemonPort: options.daemonPort,
publicBaseUrl: options.serviceProxyPublicBaseUrl,
}).proxyUrl;
}
function isLoopbackListenHost(host: string | null | undefined): boolean {

View File

@@ -450,7 +450,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
branchName: "feature-peer-env",
scriptName,
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
}),
@@ -465,16 +465,24 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
const apiEnv = readEnvFile(apiEnvPath);
const webEnv = readEnvFile(webEnvPath);
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
expect(apiEnv.PASEO_SERVICE_API_URL).toBe(
"http://api--feature-peer-env--repo.localhost:6767",
);
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe(
"http://web--feature-peer-env--repo.localhost:6767",
);
expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(apiEnv).not.toHaveProperty("PORT");
expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
expect(webEnv.PASEO_SERVICE_API_URL).toBe(
"http://api--feature-peer-env--repo.localhost:6767",
);
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe(
"http://web--feature-peer-env--repo.localhost:6767",
);
expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL);
@@ -487,14 +495,14 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
expect(Number.isInteger(webPort)).toBe(true);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.feature-peer-env.repo.localhost",
hostname: "api--feature-peer-env--repo.localhost",
port: apiPort,
workspaceId: repoDir,
projectSlug: "repo",
scriptName: "api",
},
{
hostname: "web.feature-peer-env.repo.localhost",
hostname: "web--feature-peer-env--repo.localhost",
port: webPort,
workspaceId: repoDir,
projectSlug: "repo",

View File

@@ -420,13 +420,13 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"http://api.feature-socket-service.repo.localhost:6767",
"http://api--feature-socket-service--repo.localhost:6767",
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe(
createTerminalCalls[0]?.env?.PASEO_PORT,
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
"http://api.feature-socket-service.repo.localhost:6767",
"http://api--feature-socket-service--repo.localhost:6767",
);
}
@@ -450,7 +450,7 @@ describe("runAsyncWorktreeBootstrap", () => {
String(plannedAppServerPort),
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
"http://app-server.feature-socket-service.repo.localhost:6767",
"http://app-server--feature-socket-service--repo.localhost:6767",
);
}
@@ -497,7 +497,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service",
scriptName: "web",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
});
@@ -541,7 +541,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-exit",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -580,7 +580,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-rerun",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -600,7 +600,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-rerun",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -659,7 +659,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-existing-terminal",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -701,7 +701,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-terminal-exit",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -738,7 +738,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-duplicate",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -751,7 +751,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-duplicate",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
}),
@@ -786,7 +786,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service",
scriptName: "api",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
});
@@ -794,7 +794,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(result.scriptName).toBe("api");
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.feature-socket-service.repo.localhost",
hostname: "api--feature-socket-service--repo.localhost",
port: expect.any(Number),
workspaceId: repoDir,
projectSlug: "repo",
@@ -813,6 +813,60 @@ describe("runAsyncWorktreeBootstrap", () => {
});
});
it("spawns services with public aliases and public service URLs", async () => {
commitPaseoScripts(
{
api: {
type: "service",
command: "npm run api",
},
"app-server": {
type: "service",
command: "npm run app",
},
},
"add public service script config",
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const result = await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-public-service",
scriptName: "api",
daemonPort: 6767,
serviceProxyPublicBaseUrl: "https://services.example.com",
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
});
expect(result.hostname).toBe("api--feature-public-service--repo.localhost");
expect(
routeStore.getRouteEntry("api--feature-public-service--repo.services.example.com"),
).toMatchObject({
hostname: "api--feature-public-service--repo.localhost",
publicHostname: "api--feature-public-service--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
workspaceId: repoDir,
scriptName: "api",
});
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"https://api--feature-public-service--repo.services.example.com",
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
"https://api--feature-public-service--repo.services.example.com",
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
"https://app-server--feature-public-service--repo.services.example.com",
);
});
it("refreshes a stopped service port on respawn and updates the route", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
@@ -852,7 +906,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service",
scriptName: "api",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -864,7 +918,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service",
scriptName: "worker",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -889,7 +943,7 @@ describe("runAsyncWorktreeBootstrap", () => {
lifecycle: "stopped",
exitCode: 0,
});
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toBeNull();
expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toBeNull();
const secondResult = await spawnWorkspaceScript({
repoRoot: repoDir,
@@ -898,7 +952,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service",
scriptName: "api",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -911,8 +965,8 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(secondPort).not.toBe(firstPort);
expect(secondPort).toEqual(expect.any(Number));
expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort));
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toMatchObject({
hostname: "api.feature-respawn-service.repo.localhost",
expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toMatchObject({
hostname: "api--feature-respawn-service--repo.localhost",
port: secondPort,
workspaceId: repoDir,
projectSlug: "repo",
@@ -955,20 +1009,20 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-before-rename",
scriptName: "api",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
const updateRoutesForBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged: () => {},
});
updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename");
expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([
expect.objectContaining({
hostname: "api.feature-after-rename.repo.localhost",
hostname: "api--feature-after-rename--repo.localhost",
scriptName: "api",
}),
]);
@@ -1020,7 +1074,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-collision-service",
scriptName: "app-server",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls),
}),
@@ -1029,7 +1083,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(0);
expect(routeStore.listRoutes()).toEqual([]);
expect(
routeStore.getRouteEntry("app-server.feature-collision-service.repo.localhost"),
routeStore.getRouteEntry("app-server--feature-collision-service--repo.localhost"),
).toBeNull();
writeFileSync(
@@ -1055,7 +1109,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-collision-service",
scriptName: "app-server",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls),
});
@@ -1108,7 +1162,7 @@ describe("runAsyncWorktreeBootstrap", () => {
scriptName: "web",
daemonPort: 6767,
daemonListenHost: "100.64.0.20",
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls),
});
@@ -1116,7 +1170,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"http://web.feature-remote-service.repo.localhost:6767",
"http://web--feature-remote-service--repo.localhost:6767",
);
});
});

View File

@@ -2,7 +2,6 @@ import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import { buildScriptHostname } from "../utils/script-hostname.js";
import {
getScriptConfigs,
getWorktreeTerminalSpecs,
@@ -17,7 +16,7 @@ import {
type WorktreeSetupCommandResult,
type WorktreeRuntimeEnv,
} from "../utils/worktree.js";
import { findFreePort, type ScriptRouteStore } from "./script-proxy.js";
import { findFreePort, type ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
import {
@@ -699,7 +698,8 @@ interface SpawnWorkspaceScriptOptions {
scriptName: string;
daemonPort?: number | null;
daemonListenHost?: string | null;
routeStore: ScriptRouteStore;
serviceProxyPublicBaseUrl?: string | null;
serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager;
logger?: Logger;
@@ -721,8 +721,9 @@ async function setupServiceScriptRoute(params: {
workspaceId: string;
daemonPort: number | null | undefined;
daemonListenHost: string | null | undefined;
serviceProxyPublicBaseUrl: string | null | undefined;
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
}): Promise<ServiceScriptSetupResult> {
const {
scriptConfigs,
@@ -733,10 +734,10 @@ async function setupServiceScriptRoute(params: {
workspaceId,
daemonPort,
daemonListenHost,
serviceProxyPublicBaseUrl,
existingRuntimeEntry,
routeStore,
serviceProxy,
} = params;
const hostname = buildScriptHostname({ projectSlug, branchName, scriptName });
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
@@ -779,17 +780,19 @@ async function setupServiceScriptRoute(params: {
branchName,
daemonPort,
daemonListenHost,
serviceProxyPublicBaseUrl,
peers,
});
routeStore.registerRoute({
hostname,
const registeredRoute = serviceProxy.registerWorkspaceService({
port,
workspaceId,
projectSlug,
branchName,
scriptName,
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
});
return { hostname, port, env };
return { hostname: registeredRoute.hostname, port, env };
}
async function acquireWorkspaceScriptTerminal(params: {
@@ -828,7 +831,8 @@ export async function spawnWorkspaceScript(
scriptName,
daemonPort,
daemonListenHost,
routeStore,
serviceProxyPublicBaseUrl,
serviceProxy,
runtimeStore,
terminalManager,
logger,
@@ -869,8 +873,9 @@ export async function spawnWorkspaceScript(
workspaceId,
daemonPort,
daemonListenHost,
serviceProxyPublicBaseUrl,
existingRuntimeEntry,
routeStore,
serviceProxy,
});
hostname = serviceSetup.hostname;
port = serviceSetup.port;
@@ -907,7 +912,7 @@ export async function spawnWorkspaceScript(
disposeLifecycleListeners = null;
if (input.removeRoute && hostname) {
routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName });
serviceProxy.removeWorkspaceService({ workspaceId, scriptName });
}
runtimeStore.set({
workspaceId,
@@ -975,7 +980,7 @@ export async function spawnWorkspaceScript(
} catch (error) {
disposeLifecycleListeners?.();
if (routeRegistered && hostname) {
routeStore.removeRoute(hostname);
serviceProxy.removeServiceRoutesByHostnames([hostname]);
}
if (runtimeRegistered) {
runtimeStore.remove({ workspaceId, scriptName });
@@ -998,12 +1003,12 @@ export async function spawnWorkspaceScript(
export function teardownWorktreeScripts(options: {
hostnames: string[];
routeStore: ScriptRouteStore;
serviceProxy: Pick<ServiceProxySubsystem, "removeServiceRoutesByHostnames">;
logger: Logger;
}): void {
const { hostnames, routeStore, logger } = options;
const { hostnames, serviceProxy, logger } = options;
serviceProxy.removeServiceRoutesByHostnames(hostnames);
for (const hostname of hostnames) {
routeStore.removeRoute(hostname);
logger.info({ hostname }, "Removed script proxy route");
}
}

View File

@@ -24,6 +24,7 @@ export interface CreateWorktreeCoreInput {
githubPrNumber?: number;
firstAgentContext?: FirstAgentContext;
paseoHome?: string;
worktreesRoot?: string;
runSetup?: boolean;
}
@@ -98,6 +99,7 @@ export async function createWorktreeCore(
slug: normalizedSlug,
repoRoot,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
});
if (existingWorktree) {
return { worktree: existingWorktree, intent, repoRoot, created: false };
@@ -110,6 +112,7 @@ export async function createWorktreeCore(
source: intent,
runSetup: input.runSetup ?? true,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
}),
intent,
repoRoot,

View File

@@ -107,7 +107,7 @@ function createWorkflowForRequestTest(options: {
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
scriptRouteStore: null,
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
@@ -416,7 +416,7 @@ describe("create-agent worktree setup boundary", () => {
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
scriptRouteStore: null,
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
@@ -1935,8 +1935,20 @@ describe("archivePaseoWorktree", () => {
});
});
test("clears archiving state and leaves workspace records active when worktree delete fails", async () => {
const { tempDir, repoDir } = createGitRepo();
test("archives the workspace record even when the teardown script fails", async () => {
const teardownLogPath = isPlatform("win32")
? 'Set-Content -Path (Join-Path $env:PASEO_SOURCE_CHECKOUT_PATH "teardown-start.log") -Value "started"'
: 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"';
const failingTeardownCommand = isPlatform("win32")
? 'Write-Error "boom"; exit 9'
: "echo boom 1>&2; exit 9";
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
worktree: {
teardown: [teardownLogPath, failingTeardownCommand],
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
@@ -1949,12 +1961,21 @@ describe("archivePaseoWorktree", () => {
paseoHome,
});
const archivingByWorkspaceId = new Map<string, string>();
const emittedUpdates: Array<{
kind: "upsert";
workspaceId: string;
archivingAt: string | null;
}> = [];
const archiveWorkspaceRecord = vi.fn(async () => {});
const archivedWorkspaceIds = new Set<string>();
const emittedUpdates: Array<
| {
kind: "upsert";
workspaceId: string;
archivingAt: string | null;
}
| {
kind: "remove";
workspaceId: string;
}
> = [];
const archiveWorkspaceRecord = vi.fn(async (workspaceId: string) => {
archivedWorkspaceIds.add(workspaceId);
});
await expect(
archivePaseoWorktree(
@@ -1973,6 +1994,13 @@ describe("archivePaseoWorktree", () => {
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async (workspaceIds: Iterable<string>) => {
for (const workspaceId of workspaceIds) {
if (archivedWorkspaceIds.has(workspaceId)) {
emittedUpdates.push({
kind: "remove",
workspaceId,
});
continue;
}
emittedUpdates.push({
kind: "upsert",
workspaceId,
@@ -1996,23 +2024,23 @@ describe("archivePaseoWorktree", () => {
},
{
targetPath: created.worktreePath,
repoRoot: null,
repoRoot: repoDir,
requestId: "req-archive-delete-fails",
},
),
).rejects.toThrow("cwd or worktreesRoot is required to delete a Paseo worktree");
).rejects.toThrow("Worktree teardown command failed");
expect(existsSync(created.worktreePath)).toBe(true);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(existsSync(path.join(repoDir, "teardown-start.log"))).toBe(true);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.worktreePath);
expect(emittedUpdates[0]).toEqual({
kind: "upsert",
workspaceId: created.worktreePath,
archivingAt: expect.any(String),
});
expect(emittedUpdates.at(-1)).toEqual({
kind: "upsert",
kind: "remove",
workspaceId: created.worktreePath,
archivingAt: null,
});
});

View File

@@ -20,7 +20,7 @@ import {
getWorktreeSetupProgressResults,
} from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
@@ -75,6 +75,7 @@ type AgentWorktreeSetupTimelineWriter = (input: {
interface BuildAgentSessionConfigDependencies {
paseoHome?: string;
worktreesRoot?: string;
sessionLogger: Logger;
workspaceGitService?: WorkspaceGitService;
createPaseoWorktree: (
@@ -95,16 +96,18 @@ interface BuildAgentSessionConfigDependencies {
interface CreatePaseoWorktreeInBackgroundDependencies {
paseoHome?: string;
worktreesRoot?: string;
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
emit: EmitSessionMessage;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
scriptRouteStore: ScriptRouteStore | null;
serviceProxy: ServiceProxySubsystem | null;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null;
serviceProxyPublicBaseUrl?: string | null;
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
}
@@ -158,6 +161,7 @@ interface HandleWorkspaceSetupStatusRequestDependencies {
interface HandleCreatePaseoWorktreeRequestDependencies {
paseoHome?: string;
worktreesRoot?: string;
describeWorkspaceRecord: (
result: CreatePaseoWorktreeResult,
) => Promise<WorkspaceDescriptorPayload>;
@@ -224,6 +228,7 @@ export async function buildAgentSessionConfig(
firstAgentContext,
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
},
{
resolveDefaultBranch: normalized.baseBranch
@@ -491,6 +496,7 @@ export async function handleCreatePaseoWorktreeRequest(
const commandResult = await createPaseoWorktreeCommand(
{
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
createPaseoWorktreeWorkflow: dependencies.createPaseoWorktreeWorkflow,
},
{
@@ -572,6 +578,7 @@ export async function createPaseoWorktreeWorkflow(
...input,
runSetup: false,
paseoHome: input.paseoHome ?? dependencies.paseoHome,
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
},
options?.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }

View File

@@ -39,6 +39,7 @@ export interface CreatePaseoWorktreeCommandDependencies<
Result extends CreatePaseoWorktreeResult = CreatePaseoWorktreeResult,
> {
paseoHome?: string;
worktreesRoot?: string;
createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow<Result>;
}
@@ -47,6 +48,7 @@ export type CreatePaseoWorktreeCommandInput = Omit<
"paseoHome" | "runSetup"
> & {
paseoHome?: string;
worktreesRoot?: string;
};
export type CreatePaseoWorktreeCommandResult<Result extends CreatePaseoWorktreeResult> =
@@ -73,6 +75,7 @@ export async function createPaseoWorktreeCommand<Result extends CreatePaseoWorkt
...input,
runSetup: false,
paseoHome: input.paseoHome ?? dependencies.paseoHome,
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
});
return { ok: true, createdWorktree };
} catch (error) {
@@ -118,6 +121,7 @@ export async function archivePaseoWorktreeCommand(
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
});
if (!ownership.allowed) {
@@ -134,6 +138,7 @@ export async function archivePaseoWorktreeCommand(
targetPath: resolvedTarget.targetPath,
repoRoot,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: dependencies.worktreesRoot,
requestId: input.requestId,
});
@@ -184,6 +189,10 @@ async function resolveWorktreeSlugPath(
repoRoot: string,
worktreeSlug: string,
): Promise<string> {
const worktreesRoot = await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome);
const worktreesRoot = await getPaseoWorktreesRoot(
repoRoot,
dependencies.paseoHome,
dependencies.worktreesRoot,
);
return join(worktreesRoot, worktreeSlug);
}

View File

@@ -13,7 +13,6 @@ import {
type GitHubCommandRunner,
type GitHubCommandRunnerOptions,
type GitHubCurrentPullRequestStatus,
type GitHubReadOptions,
} from "./github-service.js";
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
@@ -186,7 +185,7 @@ function githubStatusFacts(
}
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
const reads: GitHubReadOptions[] = [];
const reads: Parameters<typeof service.getCurrentPullRequestStatus>[0][] = [];
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
service.getCurrentPullRequestStatus = vi.fn(async (options) => {
reads.push(options);
@@ -662,6 +661,41 @@ describe("GitHubService", () => {
service.dispose?.();
});
it("retained fork PR status polls keep the head repository owner", async () => {
const runner = createRunner([
currentPullRequestJson({
headRefName: "open-button-targets-active-file",
headRepositoryOwner: { login: "fork-owner" },
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const reads = recordCurrentPullRequestStatusReads(service);
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
});
await vi.advanceTimersByTimeAsync(0);
expect(reads).toEqual([
expect.objectContaining({
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
reason: "self-heal-github",
}),
]);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
subscription?.unsubscribe();
service.dispose?.();
});
it("polls PR status at slow cadence after stable checks", async () => {
let now = 0;
const runner = createRunner([

View File

@@ -642,6 +642,7 @@ export interface GitHubService {
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
}): { unsubscribe: () => void };
@@ -720,6 +721,7 @@ interface InFlightCacheEntry {
interface GitHubPollTarget {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
retainCount: number;
timer: NodeJS.Timeout | null;
latestStatus: GitHubCurrentPullRequestStatus | null;
@@ -813,17 +815,25 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}
}
function getPollTargetKey(target: { cwd: string; headRef: string }): string {
function getPollTargetKey(target: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
}): string {
return buildCacheKey({
cwd: target.cwd,
method: "getCurrentPullRequestStatus",
args: { headRef: target.headRef },
args: {
headRef: target.headRef,
headRepositoryOwner: target.headRepositoryOwner,
},
});
}
function updatePollTargetAfterSuccess(update: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
status: GitHubCurrentPullRequestStatus | null;
notify: boolean;
}): void {
@@ -872,6 +882,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
await api.getCurrentPullRequestStatus({
cwd: target.cwd,
headRef: target.headRef,
headRepositoryOwner: target.headRepositoryOwner,
reason: "self-heal-github",
});
} catch (error) {
@@ -1027,6 +1038,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
updatePollTargetAfterSuccess({
cwd: input.cwd,
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
status,
notify: input.reason === "self-heal-github",
});
@@ -1241,6 +1253,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
target = {
cwd: input.cwd,
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
retainCount: 0,
timer: null,
latestStatus: null,

View File

@@ -10,6 +10,7 @@ import {
mkdirSync,
} from "fs";
import { join } from "path";
import { win32 } from "node:path";
import { tmpdir } from "os";
import {
__resetCheckoutShortstatCacheForTests,
@@ -2355,6 +2356,20 @@ const x = 1;
expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true);
});
it("matches worktrees under a custom PASEO_HOME", () => {
const customPaseoHome = process.platform === "win32" ? "C:\\paseo" : "/var/lib/paseo";
const worktreePath =
process.platform === "win32"
? win32.join(customPaseoHome, "worktrees", "project", "feature")
: `${customPaseoHome}/worktrees/project/feature`;
expect(
isPaseoWorktreePath(worktreePath, {
paseoHome: customPaseoHome,
}),
).toBe(true);
});
it("rejects paths without .paseo/worktrees segment", () => {
expect(isPaseoWorktreePath("/home/user/repo")).toBe(false);
expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false);

View File

@@ -19,7 +19,7 @@ import {
} from "../services/github-service.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import { readPaseoWorktreeMetadata } from "./worktree-metadata.js";
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
@@ -779,6 +779,7 @@ export interface MergeFromBaseOptions {
export interface CheckoutContext {
paseoHome?: string;
worktreesRoot?: string;
logger?: Pick<Logger, "trace">;
facts?: CheckoutSnapshotFacts | null;
}
@@ -882,6 +883,7 @@ export async function getMainRepoRoot(cwd: string): Promise<string> {
async function getMainRepoRootFromCommonDir(
cwd: string,
commonDir: string | null,
context?: CheckoutContext,
): Promise<string> {
if (!commonDir) {
throw new Error("Not in a git repository");
@@ -897,7 +899,14 @@ async function getMainRepoRootFromCommonDir(
envOverlay: READ_ONLY_GIT_ENV,
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare && !isPaseoWorktreePath(wt.path));
const nonBareNonPaseo = worktrees.filter(
(wt) =>
!wt.isBare &&
!isPaseoWorktreePath(wt.path, {
paseoHome: context?.paseoHome,
worktreesRoot: context?.worktreesRoot,
}),
);
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
@@ -909,8 +918,14 @@ export interface GitWorktreeEntry {
isBare?: boolean;
}
/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
export function isPaseoWorktreePath(p: string): boolean {
/** Check whether a path is under Paseo's worktree root. */
export function isPaseoWorktreePath(
p: string,
options?: { paseoHome?: string; worktreesRoot?: string },
): boolean {
if (options?.worktreesRoot || options?.paseoHome) {
return isDescendantPath(p, resolvePaseoWorktreesBaseRoot(options));
}
return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
}
@@ -1008,7 +1023,10 @@ async function getPaseoWorktreeForCwd(
return { isPaseoOwnedWorktree: false };
}
const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
const ownership = await isPaseoOwnedWorktreeCwd(cwd, {
paseoHome: context?.paseoHome,
worktreesRoot: context?.worktreesRoot,
});
if (!ownership.allowed) {
return { isPaseoOwnedWorktree: false };
}
@@ -1548,9 +1566,11 @@ export async function getCheckoutSnapshotFacts(
? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
: null;
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
const mainRepoRoot = await getMainRepoRootFromCommonDir(cwd, inspected.gitCommonDir).catch(
() => null,
);
const mainRepoRoot = await getMainRepoRootFromCommonDir(
cwd,
inspected.gitCommonDir,
context,
).catch(() => null);
let comparisonBaseRef: string | null = null;
if (
resolvedBaseRef &&

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildScriptHostname } from "./script-hostname.js";
import {
buildPublicScriptHostname,
buildPublicScriptProxyUrl,
buildScriptHostname,
} from "./script-hostname.js";
describe("buildScriptHostname", () => {
it("builds default branch hostnames with script and project labels", () => {
@@ -9,7 +13,7 @@ describe("buildScriptHostname", () => {
branchName: null,
scriptName: "web",
}),
).toBe("web.paseo.localhost");
).toBe("web--paseo.localhost");
});
it("omits the branch label for main and master", () => {
@@ -19,14 +23,14 @@ describe("buildScriptHostname", () => {
branchName: "main",
scriptName: "web",
}),
).toBe("web.paseo.localhost");
).toBe("web--paseo.localhost");
expect(
buildScriptHostname({
projectSlug: "paseo",
branchName: "master",
scriptName: "web",
}),
).toBe("web.paseo.localhost");
).toBe("web--paseo.localhost");
});
it("builds non-default branch hostnames with script, branch, and project labels", () => {
@@ -36,7 +40,7 @@ describe("buildScriptHostname", () => {
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("web.feature-auth.paseo.localhost");
).toBe("web--feature-auth--paseo.localhost");
});
it("slugifies script, default branch project, and non-default branch labels", () => {
@@ -46,7 +50,7 @@ describe("buildScriptHostname", () => {
branchName: "Feature/Auth Flow",
scriptName: "Web/API @ Dev",
}),
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
});
it("accepts already slugified labels because slugify is idempotent", () => {
@@ -56,7 +60,7 @@ describe("buildScriptHostname", () => {
branchName: "feature-auth-flow",
scriptName: "web-api-dev",
}),
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
});
it("uses untitled as the hostname-label fallback when labels collapse to empty", () => {
@@ -66,6 +70,57 @@ describe("buildScriptHostname", () => {
branchName: "***",
scriptName: "---",
}),
).toBe("untitled.untitled.untitled.localhost");
).toBe("untitled--untitled--untitled.localhost");
});
});
describe("buildPublicScriptHostname", () => {
it("uses one combined service label under the configured public base host", () => {
expect(
buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "paseo",
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("web--feature-auth--paseo.services.example.com");
});
it("omits default branch names from the public service label", () => {
expect(
buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "paseo",
branchName: "main",
scriptName: "web",
}),
).toBe("web--paseo.services.example.com");
});
it("caps the public service label to the DNS label length limit", () => {
const hostname = buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "project-".repeat(10),
branchName: "branch-".repeat(10),
scriptName: "script-".repeat(10),
});
const [serviceLabel] = hostname.split(".");
expect(serviceLabel.length).toBeLessThanOrEqual(63);
expect(serviceLabel).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
expect(hostname).toBe(`${serviceLabel}.services.example.com`);
});
});
describe("buildPublicScriptProxyUrl", () => {
it("preserves the configured public base protocol and port", () => {
expect(
buildPublicScriptProxyUrl({
publicBaseUrl: "https://services.example.com:8443/base-is-ignored",
projectSlug: "paseo",
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("https://web--feature-auth--paseo.services.example.com:8443");
});
});

View File

@@ -1,4 +1,10 @@
import { slugify } from "./worktree.js";
import {
buildLocalServiceHostname,
buildPublicServiceHostname,
buildPublicServiceProxyUrl,
} from "../server/service-proxy.js";
// Compatibility boundary for older tests/imports; new service proxy code owns hostname rules.
interface BuildScriptHostnameOptions {
projectSlug: string;
@@ -6,8 +12,8 @@ interface BuildScriptHostnameOptions {
scriptName: string;
}
function toHostnameLabel(value: string): string {
return slugify(value) || "untitled";
interface BuildPublicScriptHostnameOptions extends BuildScriptHostnameOptions {
publicBaseUrl: string;
}
export function buildScriptHostname({
@@ -15,13 +21,16 @@ export function buildScriptHostname({
branchName,
scriptName,
}: BuildScriptHostnameOptions): string {
const serviceHostnameLabel = toHostnameLabel(scriptName);
const projectHostnameLabel = toHostnameLabel(projectSlug);
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
if (isDefaultBranch) {
return `${serviceHostnameLabel}.${projectHostnameLabel}.localhost`;
}
return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`;
return buildLocalServiceHostname({ projectSlug, branchName, scriptName });
}
export function buildPublicScriptHostname({
publicBaseUrl,
...script
}: BuildPublicScriptHostnameOptions): string {
return buildPublicServiceHostname({ publicBaseUrl, ...script });
}
export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string {
return buildPublicServiceProxyUrl(options);
}

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