Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 831dba9122 Add database query CLI tool for development 2026-03-25 20:39:08 +07:00
Mohamed Boudra 296f283d44 Eliminate full timeline memory load: route committed queries to DB 2026-03-25 20:39:08 +07:00
Mohamed Boudra f27c803bf8 fix(server): keep Claude model queries open 2026-03-25 20:39:08 +07:00
Mohamed Boudra ce8128910e Fix streaming double-dispatch: suppress raw chunks during provisional buffering 2026-03-25 20:39:08 +07:00
Mohamed Boudra c2d8cbdb2e Fix delete race: skip background persist for closed agents 2026-03-25 20:39:08 +07:00
Mohamed Boudra 05094fe9e1 Phase 5: remove legacy storage paths and dead compatibility code 2026-03-25 20:39:07 +07:00
Mohamed Boudra 6027c38f8c Phase 4c: skip provider-history rehydrate when DB timeline is available 2026-03-25 20:39:07 +07:00
Mohamed Boudra eae83a53d8 Phase 4b: timeline read authority cutover to DB 2026-03-25 20:39:07 +07:00
Mohamed Boudra 17dc2801db Phase 4a: durable timeline store adapter + dual-write 2026-03-25 20:39:07 +07:00
Mohamed Boudra 3e9fd00501 Phase 3b: agent snapshot DB cutover + interface extraction 2026-03-25 20:39:07 +07:00
Mohamed Boudra 307a6e85f9 Phase 3a: project/workspace DB cutover + legacy JSON import
Bootstrap now opens PGlite and uses DB-backed project/workspace
registries as the sole runtime authority. Legacy JSON files are
imported transactionally on first startup; restart is idempotent.

Key changes:
- bootstrap.ts: DB registries replace file-backed, DB lifecycle
  managed on startup/shutdown/error
- workspace-registry-bootstrap.ts: fixed FK write order (project
  before workspace), uses list() instead of existsOnDisk()
- New transactional import module with rollback safety
- Bootstrap smoke tests for import, idempotence, and reconciliation

Audit-verified: authority cutover (13/13 checks), boundary (10/10).
2026-03-25 20:39:07 +07:00
Mohamed Boudra 7f11c0c64c Phase 2b: DB-backed project and workspace registry adapters
Add DbProjectRegistry and DbWorkspaceRegistry implementing the existing
ProjectRegistry/WorkspaceRegistry interfaces against Drizzle/PGlite.
Real PGlite contract tests verify full behavioral parity with the
file-backed implementations including FK constraints.

No runtime rewiring — bootstrap and consumers untouched.
2026-03-25 20:39:07 +07:00
Mohamed Boudra 214f85a150 Phase 2a: Drizzle + PGlite database foundation
Add Drizzle schema for all four tables (projects, workspaces,
agent_snapshots, agent_timeline_rows), PGlite bootstrap lifecycle,
migration harness, and real contract tests proving query shapes work.

No runtime authority changes — Session, AgentManager, and existing
storage call sites are untouched. CLI cleanup removes stale projection
fields from two fetchAgentTimeline call sites.

Audit-verified: correctness (all acceptance criteria met), boundary
(no runtime files touched), complexity (minor flags resolved).
2026-03-25 20:39:07 +07:00
Mohamed Boudra acda34fc70 Phase 1d: extract in-memory timeline store 2026-03-25 20:39:07 +07:00
Mohamed Boudra ae4f9e0410 Phase 1c: isolate provider history compatibility seam 2026-03-25 20:39:07 +07:00
Mohamed Boudra b3cdfb203b Phase 1b: extract workspace reconciliation service 2026-03-25 20:39:07 +07:00
Mohamed Boudra 6eb6be77df Phase 1a: collapse snapshot mutation ownership 2026-03-25 20:39:07 +07:00
Mohamed Boudra c202bd5ff7 Phase 0: simplify timeline contract and reconnect behavior 2026-03-25 20:39:07 +07:00
Mohamed Boudra cebde228b1 Add storage revamp implementation plan 2026-03-25 20:39:07 +07:00
305 changed files with 17251 additions and 18506 deletions
-2
View File
@@ -9,8 +9,6 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:
+3 -3
View File
@@ -124,7 +124,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -230,7 +230,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -339,7 +339,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
-45
View File
@@ -1,45 +0,0 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
permissions:
contents: write
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Fix lockfile and update hash
run: ./scripts/update-nix.sh
- name: Commit changes
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push
-59
View File
@@ -1,59 +0,0 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
pull_request:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: false
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix package
run: nix build .#default -o result
- name: Verify lockfile is complete
# npm silently omits resolved/integrity fields in workspace monorepos.
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
run: |
node scripts/fix-lockfile.mjs package-lock.json
git diff --exit-code package-lock.json || {
echo "ERROR: package-lock.json has missing resolved/integrity fields."
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
exit 1
}
- name: Check npmDepsHash is up to date
run: ./scripts/update-nix.sh --check
-69
View File
@@ -1,74 +1,5 @@
# Changelog
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
+2
View File
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run db:query # Show DB table row counts
npm run db:query -- "SELECT ..." # Run arbitrary SQL against PGlite
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
+10 -74
View File
@@ -4,100 +4,40 @@
<h1 align="center">Paseo</h1>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for all your coding agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
## Getting Started
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
### Prerequisites
### Headless / server mode
You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://github.com/anomalyco/opencode)
### Desktop app (recommended)
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
### CLI / headless
Install the CLI and start Paseo:
To run the daemon on a remote or headless machine:
```bash
npm install -g @getpaseo/cli
paseo
```
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
Then connect from the desktop app, mobile app, or CLI.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
## CLI
Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npx skills add getpaseo/paseo
```
Then use them in any agent conversation:
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Development
Quick monorepo package map:
@@ -117,12 +57,8 @@ npm run dev
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# repo-wide checks
npm run typecheck
```
+1 -1
View File
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
-8
View File
@@ -31,14 +31,6 @@ Your code never leaves your machine. Paseo is local-first.
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
## Packages
### `packages/server` — The daemon
+18
View File
@@ -35,6 +35,24 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
Check `$PASEO_HOME/daemon.log` for trace-level logs.
### Database queries
Run arbitrary SQL against the PGlite database:
```bash
# Show table row counts
npm run db:query
# Run any SQL
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
# Point at a specific DB directory
npm run db:query -- --db /path/to/db "SELECT ..."
```
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
## Build sync gotchas
### Relay → Daemon
+3 -16
View File
@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
## Manual step-by-step
```bash
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:check # Validate release
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
@@ -25,7 +25,6 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published
```
@@ -33,7 +32,6 @@ npm run release:finalize # Publish npm, promote draft to published
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
## Fixing a failed release build
@@ -64,23 +62,12 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
- [ ] `npm run release:patch` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
+848
View File
@@ -0,0 +1,848 @@
# Storage Revamp Plan
Status: working implementation plan
This document is meant to guide implementation and review. It captures the decisions that have already converged, names the sequencing and test gates that should not be skipped, and leaves open the product and implementation details that are still legitimately undecided.
## Goals and scope
The storage revamp moves Paseo from a mixed JSON plus in-memory model to an explicit storage layer backed by Drizzle and PGlite.
In scope:
- durable storage for project records
- durable storage for workspace records
- durable storage for agent snapshot metadata
- durable storage for committed agent timeline history
- a simplified timeline contract: committed canonical history vs provisional live updates
- removal of `epoch`/`reset`/projection-heavy timeline recovery paths
- migration and cutover from existing JSON-backed Paseo homes where source data actually exists
## Non-goals
Out of scope for this plan:
- migrating config, keypairs, server identity, or push-token files into the DB
- persisting raw provider deltas, chunk streams, or other transport-only live updates
- designing a remote/cloud database story beyond keeping the schema portable
- committing durable reasoning history in v1 unless product explicitly asks for it later
- preserving legacy timeline semantics such as `epoch`, projection metadata, or provider-history rehydrate on refresh/load
## Current state and migration hazards
Today the server persists three different kinds of state in three different ways:
- projects in `projects/projects.json`
- workspaces in `projects/workspaces.json`
- agent snapshots in `agents/{cwd}/{agent-id}.json`
Committed timeline history is different. It is still effectively an in-memory `AgentManager` concern:
- rows live in `agent.timelineRows`
- sequence numbers are assigned in memory
- retention trimming can drop older rows
- `fetchAgentTimeline` still carries `epoch`, `reset`, `gap`, projection mode, and seq-range metadata to compensate for that model
There are also two migration hazards that should be treated as foundation work, not cleanup:
1. Agent snapshot persistence is split across multiple writers.
`attachAgentStoragePersistence()` flushes `agent_state` snapshots, while `Session` also writes agent records directly for archive/unarchive and related flows. The storage revamp should collapse that into clearer ownership before the DB swap broadens the write surface.
2. Agent load and refresh still depend on provider history hydration.
`Session.ensureAgentLoaded()` resumes or recreates a provider session and then calls `hydrateTimelineFromProvider()`. That makes refresh/load behavior depend on provider-managed history, creates replay and dedup complexity, and blocks a clean storage-owned timeline contract.
The practical implication is that this is not just a database wiring task. The contract and ownership model have to be simplified first.
## Converged decisions and contracts
### Storage choice: Drizzle + PGlite
This plan assumes Drizzle plus PGlite.
Why this is the right default:
- one Postgres-flavored schema and migration pipeline for local and future hosted storage
- no native addon packaging burden in the Electron and local-daemon environment
- Drizzle keeps the schema in TypeScript and fits the existing codebase style better than a heavier codegen-oriented ORM
- PGlite is fast enough for Paseo's local CRUD and timeline query workload
This does not lock the project into every detail of local database lifecycle yet. It does lock the plan onto a Postgres-shaped schema and Drizzle-based migration workflow.
### Storage boundary
The goal is not a grand generic repository framework. The useful seams are:
- `ProjectRegistry`
- `WorkspaceRegistry`
- `AgentSnapshotStore`
- `AgentTimelineStore`
- `WorkspaceReconciliationService`
The refactor should preserve clear domain ownership:
- project and workspace records stay distinct because reconciliation and placement logic are distinct
- agent snapshot metadata stays separate from timeline history
- filesystem reconciliation stays as a service over stored records, not an accidental side effect inside session code
### Timeline contract
The room has converged on a simplified contract:
- `timeline` event with `seq` present means committed canonical history owned by Paseo storage
- `timeline` event with no `seq` means provisional live-only state and is never returned by catch-up fetches
- committed rows must already be in canonical display/history shape, not raw provider chunk shape
- `fetch-after-seq` returns committed rows only
- Phase 0 policy: reconnect does not use a subscribe-time provisional seed
- if a turn is still active after reconnect, the client resumes from future live provisional updates only
- provisional-seed ordering is therefore out of scope for Phase 0 until product explicitly chooses to add that policy
- at most one active provisional assistant stream and one active provisional reasoning stream exist per turn
- tool progress continues to correlate by existing `callId`
- reasoning is live-only for v1 unless product explicitly changes that call later
### Why `epoch` is removed
`epoch` exists today because timeline identity is not fully owned by durable storage. It resets when the in-memory window is invalidated or rebuilt, and clients carry that reset logic in public protocol state.
That is the wrong long-term contract for a DB-backed append-only history. Once committed history is storage-owned and no longer trimmed out from under clients:
- catch-up can be `after seq`
- published sequence numbers do not need to be rewritten
- clients do not need `epoch`, `reset`, or projection-derived recovery metadata
Removing `epoch` is not a cosmetic cleanup. It is the public sign that committed history is finally durable and stable.
### Why refresh/load must not rehydrate provider history
Provider history hydration made sense as a stopgap while Paseo did not own durable timeline history. It is now an obstacle.
Refresh/load must stop rehydrating provider history because it:
- makes the public history contract depend on provider-specific retention and replay behavior
- forces user-message dedup rules and other replay suppression into server logic
- creates ambiguity over whether a timeline row is provider-imported history or Paseo-owned committed history
- makes reconnect behavior depend on re-importing old provider state instead of reading committed rows from storage
The replacement contract is simpler:
- refresh/load restores agent snapshot state from Paseo storage
- committed history comes from `AgentTimelineStore`
- provisional in-flight state, if any, is subscription-scoped and may be seeded once per subscription according to the chosen policy
## Timeline consumption contract
The client model is intentionally two-layered:
- committed history is the durable tail, keyed by monotonically increasing `seq`
- provisional live state is the transient head, carried by unsequenced `timeline` events
The client keeps a committed cursor, not a provisional cursor. Provisional streaming never advances that committed cursor.
### Client mental model
The client stores:
- the last committed `seq` it has accepted for a given agent
- the committed history rows needed for the current view
- any current provisional UI state for in-progress assistant, reasoning, or tool activity
It does not store or reason about a second durable sequence space for provisional data.
### `fetch-after-seq`
`fetch-after-seq(agentId, seq)` means:
- return committed canonical history rows with `seq > cursor`
- never return provisional live state
- never return raw chunk rows
- preserve committed order
If there are no committed rows after that cursor yet, the result is empty even if an in-progress message is still streaming live.
### Subscribe-time provisional seed
A subscribe-time provisional seed, if used by the chosen policy, is a one-time delivery of the current full provisional items for that subscription.
It is not:
- a historical replay of provider deltas
- a second committed history fetch
- a second sequence space
If emitted, it must arrive before later provisional patch events for that subscription.
Phase 0 chooses the simpler policy: no subscribe-time provisional seed for now.
### Reconnect scenarios
Concrete reconnect example:
- the client has accepted committed history through `seq = 120`
- the committed tail includes rows through `120`
- the client UI also shows an in-progress assistant message in the provisional head
- that provisional assistant message has no committed seq yet
Case A: the assistant message finishes while the client is disconnected
1. The client reconnects with last committed `seq = 120`.
2. The client calls `fetch-after-seq(agentId, 120)`.
3. The server returns committed row `121`, containing the full finalized assistant message.
4. The client accepts row `121` into the durable tail and advances its committed cursor to `121`.
5. The stale provisional assistant UI from before disconnect is dropped because the committed row has now superseded it.
Case B: the assistant message has not finished by the time the client reconnects
1. The client reconnects with last committed `seq = 120`.
2. The client calls `fetch-after-seq(agentId, 120)`.
3. The server may legitimately return nothing new yet, because committed row `121` does not exist yet.
4. Live streaming resumes.
5. In Phase 0, the client continues from fresh live updates only.
6. When the assistant message finally completes, it becomes one committed row `121`.
In both cases:
- partial streaming before disconnect never advanced the committed cursor
- `fetch-after-seq` is about committed history only
- provisional streaming is transient UI state, not committed history
If the daemon restarted or the agent is unloaded:
- committed history still comes from `AgentTimelineStore`
- the client can fetch committed history by seq without the provider session being active
- provisional state is absent unless a new live subscription later seeds it
### Replacement rule without a second sequence space
The replacement rule is intentionally simple:
- committed rows are authoritative durable history
- provisional UI is best-effort and disposable
- when a committed row arrives that corresponds to previously shown provisional state, the committed row becomes the durable representation and the provisional UI is cleared
This simplicity depends on the agreed invariant that there is at most one active provisional assistant stream and one active provisional reasoning stream per turn.
This avoids introducing `epoch`, a second durable sequence space, or provider-specific replay semantics.
## Authority and ownership
The storage revamp needs explicit authority boundaries so the model cannot drift during implementation.
- `AgentTimelineStore` owns committed timeline authority
- only the committed-history authority path may assign durable `seq`
- provisional/live buffering is owned by the runtime/session path that is already handling live provider events; it is not owned by the DB
- `AgentSnapshotStore` owns durable agent snapshot persistence
- `WorkspaceReconciliationService` owns filesystem-vs-stored-workspace reconciliation
- `ProjectRegistry` and `WorkspaceRegistry` own durable structured records for those domains
- legacy structured-record import is a bootstrap path owned by the structured-record storage layer, not by ad hoc session code
Practical implications:
- providers do not assign committed seqs
- clients do not infer committed seq advancement from provisional events
- session code may read committed history, but it should not become an alternate authority for committed storage semantics
## Interfaces and module boundaries
These are design-level contracts, not implementation-level signatures.
### `AgentSnapshotStore`
Responsibilities:
- read and write durable agent snapshot metadata
- archive and unarchive snapshot records
- list and fetch stored agent snapshots without requiring an active provider session
Useful method shape:
- `get(agentId)`
- `list(filter?)`
- `upsert(snapshotRecord)`
- `archive(agentId, archivedAt)`
- `unarchive(agentId)`
### `AgentTimelineStore`
Responsibilities:
- append finalized committed history rows
- fetch committed history by tail, before-seq, or after-seq queries
- serve committed history for unloaded or restarted agents
- guarantee ordered committed seq allocation per agent
Useful method shape:
- `appendCommitted(agentId, item, metadata?) -> committedRow`
- `fetchTail(agentId, limit)`
- `fetchBeforeSeq(agentId, beforeSeq, limit)`
- `fetchAfterSeq(agentId, seq, limit?)`
- `getLatestCommittedSeq(agentId)`
Constraints:
- no raw chunk rows in durable history
- no provisional rows in durable history
- seq assignment happens only here or in the narrow authority path directly behind it
### `ProjectRegistry`
Responsibilities:
- store and fetch durable project records
- upsert, archive, and list project records
Useful method shape:
- `get(projectId)`
- `list(filter?)`
- `upsert(projectRecord)`
- `archive(projectId, archivedAt)`
### `WorkspaceRegistry`
Responsibilities:
- store and fetch durable workspace records
- upsert, archive, and list workspace records
Useful method shape:
- `get(workspaceId)`
- `list(filter?)`
- `upsert(workspaceRecord)`
- `archive(workspaceId, archivedAt)`
### `WorkspaceReconciliationService`
Responsibilities:
- own both targeted reconcile-on-register or placement-update checks and broader stale-workspace sweeps
- compare durable workspace records against filesystem reality
- mark stale or missing workspaces according to the chosen policy
- keep reconciliation logic out of session orchestration code
Useful method shape:
- `reconcileTarget(workspaceId | cwd) -> reconcileResult`
- `reconcileSweep() -> reconciliationResult`
### Timeline-facing session/service seam
One session-facing timeline contract is useful so the app/CLI path does not need to know storage internals.
Responsibilities:
- fetch committed history for initial view, older-history pagination, or reconnect
- expose live subscription semantics
- apply the committed-vs-provisional contract consistently for app and CLI consumers
Useful method shape:
- `fetchTail(agentId, limit)`
- `fetchBeforeSeq(agentId, beforeSeq, limit)`
- `fetchAfterSeq(agentId, seq, limit?)`
- `subscribe(agentId, options?)`
This seam is a consumer-facing contract exposed by the session layer so app and CLI callers do not need to know storage internals. It is not a second storage/service authority for committed timeline semantics.
## DB design and query shape
The plan does not need to freeze the final schema, but it should lock the query-driving shape.
### Main tables expected
- `projects`
- `workspaces`
- `agent_snapshots`
- `agent_timeline_rows`
### Fields that matter for core queries
For `agent_timeline_rows`:
- `agent_id`
- committed `seq`
- committed timestamp
- canonical item payload
- optional item kind or type discriminator only if it helps storage ergonomics without expanding the hot-path query surface
For `agent_snapshots`:
- `agent_id`
- provider
- cwd / workspace linkage
- lifecycle/status metadata
- timestamps needed for listing and resume decisions
- archive state
For `projects` and `workspaces`:
- durable IDs
- linkage fields
- display fields
- archive state
- timestamps needed for reconciliation and listing
### Main queries to support efficiently
Hot-path:
- fetch committed tail for an agent
- fetch older committed history before a known seq for scrollback / pagination
- fetch committed rows after a known seq for reconnect/catch-up
- fetch committed history for an unloaded or restarted agent through the same per-agent tail/after-seq path
- append one finalized committed row
- list/fetch agent snapshots for UI and session bootstrap
- list/fetch projects and workspaces for the app shell
Cold-path:
- legacy structured-record import
- archive/unarchive and reconciliation sweeps
### Likely indexes and why
- unique ordered `(agent_id, seq)` constraint/index on timeline rows for per-agent seq uniqueness, ordered append, and after-seq fetch
- index on agent snapshot status/update time for list views
- index on workspace `project_id` for grouped workspace listing
- index on archive state where it materially reduces active-record scans
This is enough to guide implementation without prematurely optimizing secondary filters or analytics-style queries. Per-type filtering is not part of the required v1 hot path.
### Not optimizing yet
The v1 design is intentionally not optimizing for:
- cross-agent global timeline queries
- full-text search over timeline history
- reasoning-history analytics
- provider-specific chunk replay
- multi-tenant or remote-hosted query patterns
## Timeline query scenarios and invariants
Primary timeline queries:
### Fetch tail for initial view
- input: `agentId`, `limit`
- result: latest committed canonical rows only
- invariant: tail fetch does not depend on an active provider session
### Fetch after seq for reconnect/catch-up
- input: `agentId`, last committed `seq`
- result: committed canonical rows with greater seq
- invariant: no provisional rows are returned
- invariant: no gaps or duplicates appear when combined with resumed live streaming
### Fetch before seq for older-history pagination
- input: `agentId`, `beforeSeq`, `limit`
- result: older committed canonical rows with `seq < beforeSeq`
- invariant: older-history pagination uses the same committed-row contract as tail and after-seq reads
- invariant: no provisional rows or raw chunk rows are returned
### Fetch committed history for unloaded or restarted agent
- input: `agentId`
- result: committed history from storage
- invariant: provider-history rehydrate is not required
### Append finalized committed row
- input: finalized logical history item
- result: one committed row with one durable seq
- invariant: no raw chunk rows are written as durable history
Core invariants:
- provisional streaming does not advance the committed seq cursor
- durable history contains finalized canonical rows only
- clients recover from disconnect by fetching committed rows after the last committed seq
- if nothing finalized while disconnected, `fetch-after-seq` may return nothing and live streaming resumes
- if something finalized while disconnected, the returned committed row supersedes stale provisional client UI
- no gaps or duplicates are allowed across catch-up plus live resume
## v1 design non-goals
The design deliberately does not support these in v1:
- durable reasoning history
- historical backfill of timeline data that never existed in Paseo storage
- provider-history rehydrate as a normal refresh/load mechanism
- raw chunk durability
- multiple simultaneous provisional assistant or reasoning streams beyond the agreed single-stream invariant
## Phase plan
Each phase below includes the required goals, sequencing, tests, exit gate, and suggested commit boundary. The phases are intentionally practical rather than over-detailed.
Suggested commit boundaries are approximate. They are there to keep reviewable slices and clear gates, not to force an artificial commit count if two adjacent steps clearly belong together.
### Phase 0: Contract and model cleanup before DB wiring
Goal:
Lock the public and internal timeline contract before introducing a new storage backend.
What changes:
- remove `epoch` from shared timeline cursor/message shapes
- define committed-vs-provisional semantics around `timeline + optional seq`
- remove projection-specific public response baggage such as `reset`, `projection`, seq-range collapse metadata, and related epoch gates
- update server/session/app catch-up logic to use seq-backed committed history plus provisional head seeding
- make reasoning explicitly live-only for v1
- document and enforce the single-provisional-stream invariant per kind
Dependencies:
- none beyond the contract decisions already converged in the room
Tests required:
- shared message parsing tests for the simplified timeline payloads
- app reducer tests for committed append, provisional merge, and reconnect without provisional seeding
- session/WebSocket tests for `live -> disconnect -> fetch-after-seq -> subscribe -> resume`
- explicit no-provisional-seed reconnect test proving that reconnect resumes from committed catch-up plus future live events only
Exit gate:
The client and server can exchange committed and provisional timeline updates without `epoch`, `reset`, or projection metadata, and all timeline catch-up tests pass with the new contract.
Can happen in parallel:
- shared message/schema cleanup
- app reducer and bootstrap policy cleanup
- server session fetch/subscribe cleanup
Must stay sequential:
- final protocol shape must settle before DB schema and timeline store work begin
Suggested commit boundaries:
- protocol and message schema cleanup
- app/session reducer and catch-up logic cleanup
- tests locking the new contract
### Phase 1: Storage boundary refactor with current backends
Goal:
Separate domain ownership and persistence seams while still using the current JSON and in-memory backends.
What changes:
- introduce explicit `AgentSnapshotStore` and `AgentTimelineStore` seams
- keep `ProjectRegistry` and `WorkspaceRegistry` as concrete domain seams rather than wrapping them in a generic repository abstraction
- extract workspace reconciliation into a dedicated `WorkspaceReconciliationService`
- collapse duplicate snapshot-writer paths so snapshot persistence ownership is explicit
- isolate current in-memory timeline logic behind the timeline store interface
- isolate refresh/load dependence on provider history rehydrate behind explicit seams so Phase 4 can remove it cleanly without another structural refactor
Dependencies:
- Phase 0 contract and message-shape cleanup should be settled enough that the extracted seams do not preserve `epoch`-era behavior by accident
Tests required:
- store contract tests for snapshot store and timeline store
- registry contract tests for projects and workspaces
- reconciliation tests using real temp directories and real filesystem mutations
- refresh/load tests proving any remaining provider-history dependency is isolated behind explicit storage/session seams rather than scattered through the runtime
Exit gate:
The server can run unchanged behavior through explicit storage interfaces, duplicate snapshot-writer ownership is collapsed, and any remaining provider-history rehydrate dependency is isolated to a narrow compatibility path that Phase 4 can delete.
Can happen in parallel:
- snapshot-store extraction
- reconciliation extraction
- timeline-store interface extraction
Must stay sequential:
- duplicate snapshot-writer cleanup should land before DB-backed snapshot cutover
- timeline store contract should settle before DB timeline work begins
Suggested commit boundaries:
- snapshot-store ownership cleanup
- reconciliation-service extraction
- timeline-store extraction with in-memory adapter
### Phase 2: DB foundation
Goal:
Land the database runtime, schema, and migration tooling without yet making the DB the source of truth for every domain.
What changes:
- add Drizzle schema and migration workflow
- add PGlite database bootstrap and lifecycle management
- define tables for projects, workspaces, agent snapshots, and committed timeline history
- choose indexes based on the first real query set, especially `agent_id + seq`
- implement DB-backed adapters for the storage seams introduced in Phase 1
Dependencies:
- Phase 1 storage seams should exist before DB adapters become the main implementation path
Tests required:
- migration tests on fresh databases
- migration tests on incremental schema upgrades
- DB adapter tests against a real PGlite database
- startup/shutdown tests for DB bootstrap in the daemon environment
Exit gate:
The repo can create, migrate, and query a real PGlite-backed Drizzle database in tests, and the new adapters satisfy the same contract tests as the current backends.
Suggested commit boundaries:
- Drizzle and PGlite foundation
- schema plus migrations
- DB adapters wired behind existing interfaces
### Phase 3: Structured-record cutover and legacy JSON import
Goal:
Move projects, workspaces, and agent snapshot metadata onto the DB, with explicit import rules from existing Paseo homes.
What changes:
- make the DB-backed project/workspace stores authoritative
- make the DB-backed agent snapshot store authoritative
- import legacy `projects.json`, `workspaces.json`, and agent snapshot JSON into the DB
- run workspace reconciliation against stored records and filesystem state
- keep archive/unarchive and related metadata flows working through the new store ownership model
Dependencies:
- Phase 2 DB bootstrap, migrations, and DB-backed structured-record adapters must be stable first
Tests required:
- import tests from existing JSON files into empty DBs
- idempotent re-import tests where the DB already contains matching rows
- reconciliation tests covering deleted directories and archived-agent edge cases
- agent list/fetch/workspace list session tests against DB-backed stores
Exit gate:
A daemon can start from a legacy Paseo home, import structured records into the DB, restart from the same home again without duplication or state drift, and serve project/workspace/agent metadata without depending on JSON files as the source of truth.
Ordered rollout note:
- first, project/workspace authority plus reconciliation and import harness
- second, restart/idempotence verification on the structured-record path
- third, agent snapshot metadata authority
- structured records should cut over before timeline storage so the timeline phase can rely on stable DB-backed agent identity and metadata
Suggested commit boundaries:
- project/workspace DB cutover plus import harness and reconciliation
- agent snapshot DB cutover and restart/idempotence tests
### Phase 4: Timeline storage and catch-up cutover
Goal:
Move committed timeline history to storage-owned append-only rows and remove provider-history rehydrate from refresh/load.
What changes:
- make `AgentTimelineStore` authoritative for committed history
- write one committed row per finalized history item
- stop storing raw chunk rows as committed history
- keep provisional live updates in memory/subscription scope only
- make fetch/catch-up query committed rows by `agent_id + seq`
- remove provider-history rehydrate from normal refresh/load flows
- remove retention-window timeline trimming and epoch-reset behavior for committed history
Dependencies:
- Phase 0 contract cleanup
- Phase 2 DB foundation
- Phase 3 structured-record cutover, so timeline rows attach to stable DB-backed agent metadata and identity
Tests required:
- timeline store contract tests against real PGlite
- live-to-commit tests for assistant, tool, and reasoning-live-only behavior
- reconnect tests covering committed catch-up plus provisional seed ordering
- migration/cutover tests proving refresh/load do not import provider history
- scenario tests for:
- live stream while connected
- disconnect after committed seq `N`, reconnect, `fetch-after-seq(N)`, then resume live without gaps or duplicates
- daemon restart, then fetch committed history without provider rehydrate
- `attach`/`logs` against an agent with committed stored history but no active provider session
Exit gate:
Committed timeline history survives daemon restart, reconnect from a known committed seq yields ordered history with no gaps or duplicates across catch-up plus live resume, including the provisional-seed case, and refresh/load no longer depend on provider history replay.
Internal sub-gates:
- write-path parity is established before the DB-backed timeline writer becomes authoritative
- `fetch-after-seq` parity is established before legacy committed-history reads are removed
- reconnect no-gap/no-duplicate behavior is proven before timeline cutover is considered complete
- refresh/load de-rehydrate removal lands only after committed-history write and read authority are stable
Can happen in parallel:
- timeline write path
- fetch/catch-up query path
- CLI/app verification against storage-backed committed history behavior
Must stay sequential:
- this phase depends on Phase 0 contract cleanup and Phase 2 DB foundation
- it should land after Phase 3 so agent and workspace identity are already DB-backed
Suggested commit boundaries:
- gated committed timeline store plus write/read parity work, not yet authoritative until matching read-path coverage exists
- authoritative fetch/catch-up cutover together with reconnect scenario coverage
- refresh/load de-rehydrate cleanup and related tests
### Phase 5: Cleanup and removal
Goal:
Delete legacy paths once DB-backed storage and the simplified contract are proven.
What changes:
- remove legacy JSON source-of-truth paths for projects, workspaces, and agent snapshots
- remove in-memory committed timeline ownership and retention trimming
- remove leftover compatibility code and dead reducers from the pre-Phase-0 timeline model
- remove provider-history hydration paths that are no longer part of refresh/load
- update architecture and developer docs to reflect the new ownership model
Dependencies:
- Phase 3 and Phase 4 must both have passed their exit gates
Tests required:
- full server typecheck and existing relevant test suites
- targeted regression tests for deleted legacy branches
- migration tests proving old homes still import correctly into the new model
Exit gate:
There is one durable storage path for structured records and committed timeline history, normal runtime no longer writes legacy JSON or in-memory committed-history branches, and no runtime behavior depends on the removed JSON/in-memory history model.
Suggested commit boundaries:
- legacy path removal
- doc updates and final contract cleanup
## Legacy data and import constraints
The migration story is intentionally narrower than a full historical backfill.
What can be imported from existing Paseo homes:
- projects from `projects/projects.json`
- workspaces from `projects/workspaces.json`
- agent snapshot metadata from agent JSON files
What cannot be broadly backfilled:
- committed timeline history that never existed in Paseo storage
- raw provider history that would need to be rehydrated on refresh/load
- provisional live state from prior daemon lifetimes
That means the honest timeline migration story is:
- existing committed history only exists for whatever Paseo has already written under the new timeline store
- after cutover, timeline history is durable and append-only
- before cutover, old homes do not gain full historical timelines unless a separate import source is defined later
This is a feature, not a gap. It keeps the new contract storage-owned instead of reintroducing provider-specific replay semantics.
## Orchestrator audit plan
Every implementation slice requires an independent audit pass before it is accepted. Audit agents verify; they do not edit files. A phase is not complete until its audit gate passes.
Audit prompts should be checklist-based and evidence-driven. The point of these passes is to catch regressions, hidden behavior changes, missing tests, two-authority drift, and unnecessary complexity before the next phase begins.
### Phase 0 audit
The audit pass must verify:
- the public contract actually removed `epoch`, `reset`, and projection-heavy response baggage where intended
- `timeline` with `seq` vs without `seq` behaves as specified
- the explicit `seq = 120 -> 121` reconnect scenario is covered by tests
- older-history pagination still works under the committed-history contract
- app reducer and session/WebSocket behavior match the agreed contract
### Phase 1 audit
The audit pass must verify:
- no functional behavior changed beyond the intended seam refactor
- duplicate snapshot-writer ownership is actually collapsed
- reconciliation ownership is actually extracted from `Session`
- provider-history dependency is isolated and not still scattered through runtime code
- seam contract tests exist and pass
### Phase 2 audit
The audit pass must verify:
- fresh migrations succeed
- incremental migrations succeed
- DB adapters pass the same contract tests as current backends
- bootstrap and shutdown are stable
- no DB path is prematurely authoritative unless explicitly intended
### Phase 3 audit
The audit pass must verify:
- import from a legacy Paseo home works
- restarting from the same legacy home does not duplicate or drift structured records
- project, workspace, and agent metadata behavior matches prior behavior
- reconciliation still surfaces stale or missing workspaces correctly
- archive, unarchive, list, and fetch flows still work
### Phase 4 audit
The audit pass must verify:
- committed history survives daemon restart
- `fetch-after-seq` and `fetch-before-seq` behave correctly
- no raw chunk rows are persisted
- reconnect satisfies the no-gap/no-duplicate invariant
- the `120 -> 121` reconnect scenario is explicitly verified
- unloaded or restarted agent history reads work without provider rehydrate
- CLI and app history behaviors such as `logs`, `attach`, and timeline fetch still work against committed stored history
### Phase 5 audit
The audit pass must verify:
- normal runtime no longer writes legacy JSON or in-memory committed-history branches
- dead compatibility branches are actually gone
- relevant test suites and typecheck pass
- legacy-home import still works under the final model
## Open questions and risks
Open product or implementation questions:
- should subscribe-time provisional seeding always happen when a turn is active, or only when the client asks for streaming state
- do we want a durable system/error timeline row for failed or canceled turns, or should those remain transport-only
- do any providers require an extra adapter constraint to uphold the single-provisional-stream invariant
Main risks and mitigations:
- risk: the plan quietly recreates projection complexity inside the DB schema
mitigation: store canonical committed rows only, and keep raw/provisional data out of durable history
- risk: refresh/load still leaks provider-history import back into the committed-history path
mitigation: make de-rehydrate behavior a named Phase 4 gate with dedicated tests
- risk: legacy import over-promises timeline backfill
mitigation: keep timeline migration constraints explicit and separate from structured-record import
- risk: the storage refactor turns into a large abstraction exercise
mitigation: keep the seam list concrete and domain-shaped; avoid generic repository frameworks
- risk: sequencing gets inverted and DB work begins before the protocol and ownership model settle
mitigation: keep Phase 0 and Phase 1 as hard prerequisites for DB timeline cutover
+170
View File
@@ -0,0 +1,170 @@
# Agent Event Stream Redesign
Status: **Implemented** (2026-03-24)
## Problem
The Claude provider had three event paths delivering the same events to the agent-manager:
1. **Foreground stream** (`stream()` → `activeForegroundTurn.queue`)
2. **Live event pump** (`streamLiveEvents()` → `liveEventQueue`) fed by the query pump
3. **JSONL history poller** (`startLiveHistoryPolling()` → `routeSdkMessageFromPump()`)
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
Codex and OpenCode were stable because they had ONE event path with no routing decision.
## Design
### Core principle
One event source per provider session. Identity-based turn ownership, not timing-based routing.
### Provider contract (`AgentSession`)
```typescript
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
// Turn lifecycle
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
interrupt(): Promise<void>;
// Event delivery (push-based)
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
// History (hydration only — never live dispatch)
streamHistory(): AsyncGenerator<AgentStreamEvent>;
// Run (uses startTurn + subscribe internally)
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
// Session metadata (unchanged)
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Method contracts
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
#### `subscribe(callback): () => void`
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
#### `interrupt(): Promise<void>`
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
### Provider-side guarantees
1. **Per-session ordering**: callbacks invoked in provider event order
2. **No concurrent callback execution**: serialized delivery per session
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
4. **Callback error isolation**: subscriber throws → provider logs and continues
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
### Event tagging
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
### User message dedup
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
## Manager
### Single subscription per session
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
### Lifecycle projection from turn identity
- After `startTurn()` resolves: foreground turn is active
- On `turn_started` for active foreground turnId: lifecycle = `running`
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
- On autonomous `turn_started`: lifecycle = `running`
- On autonomous terminal: lifecycle = `idle` or `error`
### `streamAgent()` as filtered view
```typescript
async *streamAgent(agentId, prompt, options) {
const { turnId } = await session.startTurn(prompt, options);
agent.activeForegroundTurnId = turnId;
// Foreground turn waiter yields events matching this turnId
// Ends when terminal event for turnId arrives
}
```
### State model
| Concept | Implementation |
|---------|---------------|
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
| Lifecycle projection | From turn events via turnId matching |
| Cancellation | `session.interrupt()` + await waiter settlement |
## What was deleted
- `stream()` from `AgentSession` interface and all providers
- `Pushable<T>` async queue from all providers
- `streamLiveEvents()` capability
- `activeForegroundTurn` + foreground queue in Claude provider
- `liveEventQueue` in Claude provider
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
- `startLiveEventPump()` in manager
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
- `shouldSuppressLiveUserMessageEcho()` in manager
- `startLiveHistoryPolling()` for live dispatch
- `snapHistoryOffsetToEnd()`
- `pendingRun` as iterator reference
## Integration tests
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
| Test | What it verifies |
|------|-----------------|
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
| Interruption | Start long task → interrupt → turn_canceled arrives |
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
| Fast-fail | Quick error produces clean terminal, no stale events |
| User message dedup | Exactly one user_message with matching text in event log |
### Invariants (asserted on every test)
1. For each foreground turnId, exactly ONE `user_message` event
2. Every `turn_started` has exactly one matching terminal
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
4. Autonomous turns between foreground turns are visible with distinct turnIds
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-59
View File
@@ -1,59 +0,0 @@
{
description = "Paseo - self-hosted daemon for AI coding agents";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{
self,
nixpkgs,
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = system: import nixpkgs { inherit system; };
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
in
{
default = paseo;
paseo = paseo;
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
{
imports = [ ./nix/module.nix ];
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs.python3
];
};
}
);
};
}
-172
View File
@@ -1,172 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.paseo;
in
{
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
package = lib.mkPackageOption pkgs "paseo" { };
user = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "User account under which Paseo runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "Group under which Paseo runs.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default =
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/${cfg.user}/.paseo";
defaultText = lib.literalExpression ''
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/''${cfg.user}/.paseo"
'';
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "Port for the Paseo daemon to listen on.";
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the Paseo daemon to bind to.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
Set to `true` to allow any host (not recommended).
'';
};
relay = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
};
};
inheritUserEnvironment = lib.mkOption {
type = lib.types.bool;
default = cfg.user != "paseo";
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
description = ''
Whether to include the user's profile PATH in the service environment.
When Paseo runs as a real user (not the default system user), AI agents
need access to the user's tools (git, ssh, etc.). This adds the user's
NixOS profile and system paths so agents can use them without manually
setting PATH.
Enabled by default when `user` is set to a non-default value.
'';
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
}
'';
description = "Extra environment variables for the Paseo daemon.";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
];
systemd.services.paseo = {
description = "Paseo - self-hosted daemon for AI coding agents";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment {
# mkForce overrides the default PATH from NixOS's systemd module (which
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
# includes /run/current-system/sw/bin which is a superset of those.
PATH = lib.mkForce (lib.concatStringsSep ":" [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.allowedHosts == true) {
PASEO_ALLOWED_HOSTS = "true";
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
} // cfg.environment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart =
"${cfg.package}/bin/paseo-server"
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
Restart = "on-failure";
RestartSec = 5;
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
KillSignal = "SIGTERM";
TimeoutStopSec = 15;
};
};
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
-144
View File
@@ -1,144 +0,0 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
}:
buildNpmPackage rec {
pname = "paseo";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
!(lib.hasPrefix "/packages/app/src" relPath)
&& !(lib.hasPrefix "/packages/app/assets" relPath)
&& !(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
&& !(lib.hasPrefix "/packages/website/src" relPath)
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store";
};
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-Cz3xidzBIWER4ktn3wWzT9PDm9PnipVA7XnsTQC440U=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
# We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty compilation)
makeWrapper
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
];
# Don't use the default npm build hook — we need a custom build sequence
dontNpmBuild = true;
buildPhase = ''
runHook preBuild
# Rebuild only node-pty (native addon for terminal emulation).
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
# intentionally left unbuilt — they're lazily loaded and gracefully
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/paseo
# Copy root package metadata
cp package.json $out/lib/paseo/
# Copy node_modules (preserving workspace symlinks)
cp -a node_modules $out/lib/paseo/
# Auto-detect which @getpaseo/* packages were built by build:daemon
# (they'll have a dist/ directory). Copy those and remove the rest.
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
name=$(basename "$link")
if [ -d "packages/$name/dist" ]; then
mkdir -p "$out/lib/paseo/packages/$name"
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
if [ -d "packages/$name/node_modules" ]; then
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
fi
else
rm -f "$link"
fi
done
# Copy CLI bin entry
mkdir -p $out/lib/paseo/packages/cli/bin
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
# Copy extra server files referenced at runtime
for f in agent-prompt.md .env.example; do
if [ -f packages/server/$f ]; then
cp packages/server/$f $out/lib/paseo/packages/server/
fi
done
# Copy server scripts (including supervisor-entrypoint) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
fi
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
--set NODE_PATH "$out/lib/paseo/node_modules"
runHook postInstall
'';
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+1096 -100
View File
File diff suppressed because it is too large Load Diff
+9 -16
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.38",
"version": "0.1.33",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -37,6 +37,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
@@ -49,13 +50,13 @@
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
@@ -68,7 +69,6 @@
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
@@ -77,14 +77,7 @@
"development",
"mcp"
],
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"author": "moboudra",
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
+6 -9
View File
@@ -448,15 +448,12 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
-228
View File
@@ -1,228 +0,0 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
await page.goto(workspaceRoute);
// Wait for daemon connection (sidebar shows host label)
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
// The workspace should now query listTerminals and discover our terminal.
// Click the terminal tab if it auto-appeared, or wait for it.
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
if (!surfaceVisible) {
// Terminal tab might not be focused — look for it in the tab row and click it
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
if (tabExists) {
await terminalTab.click();
} else {
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
await newTerminalBtn.click();
}
}
// Wait for terminal surface to be visible
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -1,160 +0,0 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
THROUGHPUT_BUDGET_MS,
);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.38",
"version": "0.1.33",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.38",
"@getpaseo/highlight": "0.1.38",
"@getpaseo/server": "0.1.38",
"@getpaseo/expo-two-way-audio": "0.1.33",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/server": "0.1.33",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -0,0 +1,36 @@
export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
isDesktopStartupRace: boolean;
daemonCount: number;
pathname: string;
}): boolean {
if (input.onlineServerId) {
return false;
}
if (input.pathname === WELCOME_ROUTE) {
return false;
}
if (input.hasTimedOut) {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
pathname: string;
isDesktopStartupRace: boolean;
daemonCount: number;
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false;
}
if (input.pathname !== "/" && input.pathname !== "") {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
+96 -136
View File
@@ -15,7 +15,7 @@ import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -25,14 +25,10 @@ import {
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useCallback,
useContext,
useState,
useEffect,
@@ -69,7 +65,6 @@ import {
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -81,18 +76,7 @@ import {
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
export type HostRuntimeBootstrapState = {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
});
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
const router = useRouter();
@@ -220,99 +204,41 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
}, []);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!cancelled) {
setPhase("error");
setError(bootstrapResult.error);
}
return;
}
void store
.loadFromStorage()
.then(() => {
if (cancelled) {
return;
}
setPhase("connecting");
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setPhase("online");
setError(null);
setReady(true);
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
}
}
};
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
});
return () => {
cancelled = true;
};
}, [retryToken]);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
);
}, []);
return (
<HostRuntimeBootstrapContext.Provider value={state}>
<HostRuntimeBootstrapContext.Provider value={ready}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).phase === "online";
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext);
}
@@ -473,30 +399,24 @@ function MobileGestureWrapper({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
const isLoading = settingsLoading || !storeReady;
// Apply theme setting on mount and when it changes
useEffect(() => {
if (settingsLoading) return;
if (isLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(settings.theme);
}
}, [settingsLoading, settings.theme]);
}, [isLoading, settings.theme]);
useEffect(() => {
if (settingsLoading || Platform.OS !== "web") {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
});
}, [settingsLoading, resolvedTheme]);
if (isLoading) {
return <LoadingView />;
}
return (
<VoiceProvider>
@@ -599,38 +519,6 @@ function FaviconStatusSync() {
return null;
}
function RootStack() {
const storeReady = useStoreReady();
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="index" />
</Stack>
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
@@ -651,6 +539,57 @@ function NavigationActiveWorkspaceObserver() {
return null;
}
function LoadingView({ message }: { message?: string } = {}) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
{message ? (
<Text
style={{
color: darkTheme.colors.foregroundMuted,
marginTop: 16,
fontSize: 14,
}}
>
{message}
</Text>
) : null}
</View>
);
}
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
<Text
style={{
color: darkTheme.colors.foreground,
marginTop: 16,
textAlign: "center",
}}
>
No host configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
@@ -667,7 +606,28 @@ export default function RootLayout() {
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<RootStack />
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
+75 -27
View File
@@ -1,22 +1,20 @@
import { useEffect, useSyncExternalStore } from "react";
import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHosts,
} from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from "@/app-support/index-startup";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
@@ -35,31 +33,81 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
}
return firstOnlineServerId;
},
() => null,
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!storeReady) {
if (!onlineServerId) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
const targetRoute = anyOnlineServerId
? buildHostRootRoute(anyOnlineServerId)
: WELCOME_ROUTE;
router.replace(targetRoute as any);
}, [anyOnlineServerId, pathname, router, storeReady]);
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return;
}
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />;
}
return null;
}
@@ -8,7 +8,6 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import {
DropdownMenu,
DropdownMenuContent,
@@ -610,7 +609,6 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => {
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -711,17 +709,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error);
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
@@ -732,23 +719,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
const activeModelId = modelSelection.activeModelId;
if (activeModelId) {
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: activeModelId,
thinkingByModel: {
[activeModelId]: thinkingOptionId,
},
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error);
});
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
@@ -10,7 +10,6 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
title?: string;
rightContent?: ReactNode;
borderless?: boolean;
}
interface SidebarMenuToggleProps {
@@ -76,7 +75,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -91,7 +90,6 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
}
right={rightContent}
leftStyle={styles.left}
borderless={borderless}
/>
);
}
@@ -6,6 +6,7 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
getIsDesktopMac,
} from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
@@ -15,14 +16,13 @@ interface ScreenHeaderProps {
right?: ReactNode;
leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean;
}
/**
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -31,10 +31,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
@@ -44,11 +42,7 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
<View
style={[
styles.row,
{
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
},
borderless && styles.borderless,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
]}
{...dragHandlers}
>
@@ -89,7 +83,4 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
borderless: {
borderBottomWidth: 0,
},
}));
+5 -7
View File
@@ -520,7 +520,6 @@ function MobileSidebar({
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
@@ -557,7 +556,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -582,7 +581,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -682,7 +681,7 @@ function DesktopSidebar({
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.side === 'left' ? (
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
@@ -702,7 +701,6 @@ function DesktopSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
/>
)}
@@ -738,7 +736,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -763,7 +761,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
+16 -22
View File
@@ -346,13 +346,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
[onChangeText, onSubmit, images, isAgentRunning],
);
const handleDictationError = useCallback(
(error: Error) => {
console.error("[MessageInput] Dictation error:", error);
toast.error(error.message);
},
[toast],
);
const handleDictationError = useCallback((error: Error) => {
console.error("[MessageInput] Dictation error:", error);
}, []);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
@@ -954,20 +950,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.leftButtonGroup}>
{onPickImages && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</Pressable>
<TooltipTrigger
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
@@ -1209,7 +1203,7 @@ const styles = StyleSheet.create(((theme: any) => ({
leftButtonGroup: {
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
},
rightButtonGroup: {
flexDirection: "row",
@@ -31,7 +31,6 @@ import {
ChevronRight,
Copy,
ExternalLink,
FolderPlus,
FolderGit2,
GitPullRequest,
Monitor,
@@ -74,7 +73,6 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -115,7 +113,6 @@ interface SidebarWorkspaceListProps {
isRefreshing?: boolean;
onRefresh?: () => void;
onWorkspacePress?: () => void;
onAddProject?: () => void;
listFooterComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -395,15 +392,15 @@ function NewWorktreeButton({
}}
disabled={loading}
accessibilityRole="button"
accessibilityLabel={`Create a new workspace for ${displayName}`}
accessibilityLabel={`Create a new worktree for ${displayName}`}
testID={testID}
>
{({ hovered, pressed }) =>
loading ? (
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
) : (
<FolderPlus
size={15}
<Plus
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
@@ -414,7 +411,7 @@ function NewWorktreeButton({
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New workspace</Text>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
@@ -1634,7 +1631,6 @@ export function SidebarWorkspaceList({
isRefreshing = false,
onRefresh,
onWorkspacePress,
onAddProject,
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
@@ -1904,18 +1900,7 @@ export function SidebarWorkspaceList({
const content = (
<>
{projects.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyText}>Add a project to get started</Text>
<Button
variant="ghost"
size="sm"
leftIcon={Plus}
onPress={onAddProject}
>
Add project
</Button>
</View>
<Text style={styles.emptyText}>No projects yet</Text>
) : (
<DraggableList
testID="sidebar-project-list"
@@ -1978,27 +1963,11 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
workspaceListContainer: {},
emptyContainer: {
marginHorizontal: theme.spacing[2],
marginTop: theme.spacing[4],
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
alignItems: "center",
gap: theme.spacing[3],
},
emptyTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginTop: theme.spacing[8],
marginHorizontal: theme.spacing[2],
},
projectRow: {
minHeight: 36,
@@ -886,10 +886,7 @@ function SplitPaneView({
style={[
styles.paneTabs,
isFocusModeEnabled &&
trafficLightPadding.side && {
paddingLeft: trafficLightPadding.left,
paddingRight: trafficLightPadding.right,
},
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
]}
>
<WorkspaceDesktopTabsRow
@@ -565,18 +565,6 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const handleContextMenu = () => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
return;
}
const hasSelection = Boolean(window.getSelection()?.toString());
void showContextMenu({
kind: "terminal",
hasSelection,
});
};
return (
<div
@@ -598,10 +586,6 @@ export default function TerminalEmulator({
onPointerDown={() => {
runtimeRef.current?.focus();
}}
onContextMenu={(event) => {
event.preventDefault();
handleContextMenu();
}}
>
<div
ref={hostRef}
+4 -49
View File
@@ -1,19 +1,11 @@
import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
import type { PropsWithChildren, ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "sm" | "md" | "lg";
type LeftIcon =
| ReactElement
| ComponentType<{ color: string; size: number }>
| ((color: string) => ReactElement)
| null;
const ICON_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
const styles = StyleSheet.create((theme) => ({
base: {
flexDirection: "row",
@@ -75,12 +67,6 @@ const styles = StyleSheet.create((theme) => ({
textDestructive: {
color: theme.colors.palette.white,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
}));
export function Button({
@@ -97,14 +83,11 @@ export function Button({
Omit<PressableProps, "style"> & {
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: LeftIcon;
leftIcon?: ReactElement | null;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const variantStyle =
variant === "default"
? styles.default
@@ -117,47 +100,19 @@ export function Button({
: styles.destructive;
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
const isGhostHovered = hovered && variant === "ghost";
const resolvedTextStyle = [
styles.text,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
textStyle,
isGhostHovered ? styles.textGhostHovered : null,
];
function renderIcon() {
if (!leftIcon) return null;
// Pre-rendered element — pass through
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function
if (typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0) {
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return <View><Icon color={color} size={iconSize} /></View>;
}
return (
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={({ pressed }) => [
styles.base,
sizeStyle,
@@ -167,7 +122,7 @@ export function Button({
style,
]}
>
{renderIcon()}
{leftIcon ? <View>{leftIcon}</View> : null}
<Text style={resolvedTextStyle}>{children}</Text>
</Pressable>
);
@@ -278,10 +278,7 @@ export function DropdownMenuContent({
setModalVisible(true);
setClosing(false);
} else if (modalVisible) {
// Avoid leaving an invisible full-screen Modal mounted on native when
// the exit animation callback does not fire.
setClosing(false);
setModalVisible(false);
setClosing(true);
}
}, [open, modalVisible]);
+3 -149
View File
@@ -1,17 +1,11 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useState } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
@@ -85,39 +79,6 @@ const styles = StyleSheet.create((theme) => ({
actionTextPrimary: {
color: theme.colors.accentForeground,
},
hostList: {
width: "100%",
maxWidth: 420,
marginTop: theme.spacing[6],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[4],
gap: theme.spacing[2],
},
hostRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
hostLabel: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostStatus: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
hostStatusError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
versionLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -126,89 +87,6 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
const lastError = snapshot?.lastError ?? null;
let dotColor: string;
let statusText: string;
let isError = false;
switch (status) {
case "online":
dotColor = theme.colors.success;
statusText = "Online";
break;
case "connecting":
case "idle":
dotColor = theme.colors.foregroundMuted;
statusText = "Connecting…";
break;
case "offline":
dotColor = theme.colors.foregroundMuted;
statusText = "Offline";
break;
case "error":
dotColor = theme.colors.destructive;
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
isError = true;
break;
}
return (
<View style={styles.hostRow}>
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
<Text style={styles.hostLabel} numberOfLines={1}>
{label}
</Text>
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
{statusText}
</Text>
</View>
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
@@ -227,8 +105,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
hostname: string | null;
} | null>(null);
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
@@ -243,16 +119,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
),
);
useEffect(() => {
if (!anyOnlineServerId) {
return;
}
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
}, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any);
@@ -307,8 +173,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
return (
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
@@ -322,9 +186,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text>
<View style={styles.actions}>
{actions.map((action) => {
@@ -347,14 +209,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
);
})}
</View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
-4
View File
@@ -19,10 +19,6 @@ export const MAX_CONTENT_WIDTH = 820;
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Windows/Linux window controls (minimize/maximize/close) — top-right
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false;
@@ -4,10 +4,6 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -35,59 +31,55 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(targets.backdropOpacity, {
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [
isOpen,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
mobileView,
desktopFileExplorerOpen,
]);
const animateToOpen = () => {
+13 -16
View File
@@ -49,7 +49,6 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -296,10 +295,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, []);
useEffect(() => {
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
previousAgentStatusRef.current,
sessionAgents,
);
if (!sessionAgents) {
previousAgentStatusRef.current.clear();
return;
}
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
}, [sessionAgents]);
const hydrateWorkspaces = useCallback(
@@ -521,9 +525,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
cursor: { seq: cursor.endSeq },
limit: 0,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
@@ -749,13 +752,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
const requestCanonicalCatchUp = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
(agentId: string, cursor: { endSeq: number }) => {
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
cursor: { seq: cursor.endSeq },
limit: 0,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
@@ -858,7 +860,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (
current &&
current.epoch === result.cursor.epoch &&
current.startSeq === result.cursor.startSeq &&
current.endSeq === result.cursor.endSeq
) {
@@ -963,7 +964,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubAgentStream = client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload;
const { agentId, event, timestamp, seq } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event as AgentStreamEventPayload;
if (
@@ -1005,7 +1006,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const result = processAgentStreamEvent({
event: streamEvent,
seq,
epoch,
currentTail,
currentHead,
currentCursor,
@@ -1029,8 +1029,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (
current &&
typeof seq === "number" &&
typeof epoch === "string" &&
current.epoch === epoch &&
seq >= current.startSeq &&
seq <= current.endSeq
) {
@@ -1039,7 +1037,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (
current &&
current.epoch === nextCursor.epoch &&
current.startSeq === nextCursor.startSeq &&
current.endSeq === nextCursor.endSeq
) {
@@ -1,72 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { reconcilePreviousAgentStatuses } from "./session-status-tracking";
function createAgent(status: Agent["status"]): Agent {
return {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status,
createdAt: new Date(0),
updatedAt: new Date(0),
lastUserMessageAt: null,
lastActivityAt: new Date(0),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent",
cwd: "/tmp",
model: null,
labels: {},
projectPlacement: null,
};
}
describe("reconcilePreviousAgentStatuses", () => {
it("preserves previously seen status for existing agents", () => {
const previous = new Map([["agent-1", "running" as const]]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("seeds newly seen agents from the current snapshot", () => {
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
expect(result).toEqual(new Map([["agent-1", "idle"]]));
});
it("removes agents that are no longer present", () => {
const previous = new Map([
["agent-1", "running" as const],
["agent-2", "idle" as const],
]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("clears all tracked statuses when the session is unavailable", () => {
const previous = new Map([["agent-1", "running" as const]]);
const result = reconcilePreviousAgentStatuses(previous, undefined);
expect(result).toEqual(new Map());
});
});
@@ -1,29 +0,0 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
export function reconcilePreviousAgentStatuses(
previousStatuses: Map<string, AgentLifecycleStatus>,
sessionAgents: Map<string, Agent> | undefined,
): Map<string, AgentLifecycleStatus> {
if (!sessionAgents) {
return new Map();
}
const nextStatuses = new Map(previousStatuses);
const seenAgentIds = new Set<string>();
for (const agent of sessionAgents.values()) {
seenAgentIds.add(agent.id);
if (!nextStatuses.has(agent.id)) {
nextStatuses.set(agent.id, agent.status);
}
}
for (const agentId of nextStatuses.keys()) {
if (!seenAgentIds.has(agentId)) {
nextStatuses.delete(agentId);
}
}
return nextStatuses;
}
@@ -9,13 +9,9 @@ import {
type TimelineCursor,
} from "./session-stream-reducers";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
return {
seqStart: seq,
seq,
provider: "claude",
item: { type, text },
timestamp: new Date(1000 + seq).toISOString(),
@@ -33,22 +29,30 @@ function makeTimelineEvent(
} as AgentStreamEventPayload;
}
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: { type: "user_message", text },
} as AgentStreamEventPayload;
item: {
type: "tool_call",
callId: "call-1",
name: "shell",
status,
detail: {
type: "shell",
command: "pwd",
},
error: null,
},
};
}
const baseTimelineInput: ProcessTimelineResponseInput = {
payload: {
agentId: "agent-1",
direction: "after",
reset: false,
epoch: "epoch-1",
startCursor: null,
endCursor: null,
startSeq: null,
endSeq: null,
entries: [],
error: null,
},
@@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
const baseStreamInput: ProcessAgentStreamEventInput = {
event: makeTimelineEvent("hello"),
seq: undefined,
epoch: undefined,
currentTail: [],
currentHead: [],
currentCursor: undefined,
@@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
timestamp: new Date(2000),
};
// ---------------------------------------------------------------------------
// processTimelineResponse
// ---------------------------------------------------------------------------
describe("processTimelineResponse", () => {
it("returns error path when payload.error is set", () => {
const result = processTimelineResponse({
@@ -93,35 +92,10 @@ describe("processTimelineResponse", () => {
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.head).toBe(baseTimelineInput.currentHead);
expect(result.cursorChanged).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("returns error with no init resolution when no deferred exists", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: false,
payload: {
...baseTimelineInput.payload,
error: "timeout",
},
});
expect(result.error).toBe("timeout");
expect(result.initResolution).toBe(null);
expect(result.clearInitializing).toBe(true);
});
it("replaces tail and clears head when reset=true", () => {
const existingTail: StreamItem[] = [
{
kind: "user_message",
id: "old",
text: "old message",
timestamp: new Date(500),
},
];
const existingHead: StreamItem[] = [
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
const provisionalHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-1",
@@ -132,518 +106,280 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: existingTail,
currentHead: existingHead,
payload: {
...baseTimelineInput.payload,
reset: true,
startCursor: { seq: 1 },
endCursor: { seq: 3 },
entries: [
makeTimelineEntry(1, "first"),
makeTimelineEntry(2, "second"),
makeTimelineEntry(3, "third"),
],
},
});
expect(result.tail).not.toBe(existingTail);
expect(result.tail.length).toBeGreaterThan(0);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
});
expect(result.error).toBe(null);
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("sets cursor to null when reset=true but no cursors in payload", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
payload: {
...baseTimelineInput.payload,
reset: true,
entries: [],
},
});
expect(result.cursor).toBe(null);
expect(result.cursorChanged).toBe(true);
});
it("performs bootstrap tail init with catch-up side effect", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: provisionalHead,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "tail",
payload: {
...baseTimelineInput.payload,
direction: "tail",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 5 },
startSeq: 1,
endSeq: 5,
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
},
});
// Bootstrap tail replaces
expect(result.tail.length).toBeGreaterThan(0);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
});
// Should have catch-up side effect
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 5,
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
expect(catchUp).toEqual({
type: "catch_up",
cursor: { endSeq: 5 },
});
});
it("appends incrementally for contiguous seqs", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
it("prepends older committed history for before pagination", () => {
const currentTail: StreamItem[] = [
{
kind: "assistant_message",
id: "tail-3",
text: "newer",
timestamp: new Date(3000),
},
});
expect(result.tail.length).toBeGreaterThan(0);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
});
expect(result.error).toBe(null);
});
it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
];
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(10, "far ahead")],
},
});
// Gap should trigger catch-up
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 3,
});
});
it("drops stale entries silently", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 8,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
},
});
// No new items appended (all dropped as stale)
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.cursorChanged).toBe(false);
});
it("drops entries with epoch mismatch", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-2",
entries: [makeTimelineEntry(6, "different epoch")],
},
});
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.cursorChanged).toBe(false);
});
it("resolves init when deferred matches direction", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "after",
payload: {
...baseTimelineInput.payload,
direction: "after",
entries: [],
},
});
expect(result.initResolution).toBe("resolve");
expect(result.clearInitializing).toBe(true);
});
it("does not resolve init when directions differ (before vs after)", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "after",
currentTail,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
entries: [],
startSeq: 1,
endSeq: 2,
entries: [
makeTimelineEntry(1, "hello", "user_message"),
makeTimelineEntry(2, "older"),
],
},
});
// "before" direction doesn't match "after" initRequestDirection,
// and "before" is not a bootstrap tail path, so init should NOT resolve
expect(result.initResolution).toBe(null);
expect(result.clearInitializing).toBe(false);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
startSeq: 1,
endSeq: 4,
});
expect(result.tail).toHaveLength(3);
expect(result.tail[0]?.kind).toBe("user_message");
expect(result.tail[1]?.kind).toBe("assistant_message");
expect(result.tail[2]).toBe(currentTail[0]);
});
it("clears initializing even without deferred", () => {
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "partial",
timestamp: new Date(120000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: false,
currentHead,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
entries: [],
},
});
expect(result.clearInitializing).toBe(true);
expect(result.initResolution).toBe(null);
});
it("always includes flush_pending_updates side effect on success", () => {
const result = processTimelineResponse({
...baseTimelineInput,
payload: {
...baseTimelineInput.payload,
entries: [],
},
});
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("initializes cursor when no existing cursor on first entries", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: undefined,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
startSeq: 121,
endSeq: 121,
entries: [makeTimelineEntry(121, "finalized reply")],
},
});
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 2,
endSeq: 121,
});
expect(result.tail[result.tail.length - 1]).toMatchObject({
kind: "assistant_message",
text: "finalized reply",
});
});
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "still streaming",
timestamp: new Date(120000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
currentHead,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
startSeq: null,
endSeq: null,
entries: [],
},
});
expect(result.head).toBe(currentHead);
expect(result.cursorChanged).toBe(false);
expect(result.tail).toBe(baseTimelineInput.currentTail);
});
it("requests catch-up when committed rows arrive with a forward gap", () => {
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
startSeq: 125,
endSeq: 125,
entries: [makeTimelineEntry(125, "far ahead")],
},
});
expect(result.cursorChanged).toBe(false);
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.sideEffects).toContainEqual({
type: "catch_up",
cursor: { endSeq: 120 },
});
});
});
// ---------------------------------------------------------------------------
// processAgentStreamEvent
// ---------------------------------------------------------------------------
describe("processAgentStreamEvent", () => {
it("passes through non-timeline events without cursor changes", () => {
const turnEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
it("treats seq-less timeline events as provisional head updates", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnEvent,
event: makeTimelineEvent("partial"),
seq: undefined,
epoch: undefined,
});
expect(result.changedHead).toBe(true);
expect(result.changedTail).toBe(false);
expect(result.head).toHaveLength(1);
expect(result.head[0]).toMatchObject({
kind: "assistant_message",
text: "partial",
});
expect(result.cursorChanged).toBe(false);
expect(result.cursor).toBe(null);
expect(result.sideEffects).toEqual([]);
});
it("accepts timeline event with cursor advance", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 4,
};
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "partial",
timestamp: new Date(1000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("new chunk"),
seq: 5,
epoch: "epoch-1",
currentCursor: existingCursor,
event: makeTimelineEvent("finalized reply"),
seq: 121,
currentHead,
currentCursor,
});
expect(result.changedTail).toBe(true);
expect(result.changedHead).toBe(true);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
endSeq: 121,
});
expect(result.tail[result.tail.length - 1]).toMatchObject({
kind: "assistant_message",
text: "finalized reply",
});
expect(result.sideEffects).toEqual([]);
});
it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 4,
};
it("replaces provisional tool progress when the committed tool row arrives", () => {
const provisional = processAgentStreamEvent({
...baseStreamInput,
event: makeToolCallEvent("running"),
seq: undefined,
});
const committed = processAgentStreamEvent({
...baseStreamInput,
event: makeToolCallEvent("completed"),
seq: 8,
currentHead: provisional.head,
currentTail: provisional.tail,
currentCursor: { startSeq: 1, endSeq: 7 },
});
expect(committed.head).toEqual([]);
expect(committed.tail).toHaveLength(1);
expect(committed.tail[0]).toMatchObject({
kind: "tool_call",
payload: {
source: "agent",
data: {
callId: "call-1",
status: "completed",
},
},
});
});
it("requests catch-up when a committed live row skips ahead", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("far ahead"),
seq: 10,
epoch: "epoch-1",
currentCursor: existingCursor,
seq: 125,
currentCursor: { startSeq: 1, endSeq: 120 },
});
expect(result.cursorChanged).toBe(false);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 4,
});
});
it("drops stale timeline event", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 8,
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("old"),
seq: 5,
epoch: "epoch-1",
currentCursor: existingCursor,
});
expect(result.cursorChanged).toBe(false);
expect(result.sideEffects).toContainEqual({
type: "catch_up",
cursor: { endSeq: 120 },
});
});
it("clears provisional head on terminal turn events without committing it to tail", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: {
type: "turn_completed",
provider: "claude",
},
currentHead: [
{
kind: "thought",
id: "reasoning-1",
text: "thinking",
timestamp: new Date(1000),
status: "loading",
},
],
});
expect(result.changedHead).toBe(true);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("drops timeline event with epoch mismatch", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("wrong epoch"),
seq: 6,
epoch: "epoch-2",
currentCursor: existingCursor,
});
expect(result.cursorChanged).toBe(false);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("initializes cursor when none exists", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("first"),
seq: 1,
epoch: "epoch-1",
currentCursor: undefined,
});
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 1,
});
});
it("derives optimistic idle status on turn_completed for running agent", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent).not.toBe(null);
expect(result.agent!.status).toBe("idle");
expect(result.agent!.updatedAt.getTime()).toBe(2000);
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
});
it("derives optimistic error status on turn_failed for running agent", () => {
const turnFailedEvent: AgentStreamEventPayload = {
type: "turn_failed",
provider: "claude",
error: "something broke",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnFailedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.status).toBe("error");
});
it("does not change agent when status is not running", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "idle",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when no agent is provided", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: null,
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("preserves updatedAt when agent timestamp is newer than event", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(5000),
lastActivityAt: new Date(5000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.updatedAt.getTime()).toBe(5000);
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
});
it("does not produce agent patch for non-terminal events", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("just text"),
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
seq: 1,
epoch: "epoch-1",
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
expect(result.head).toEqual([]);
expect(result.tail).toEqual([]);
});
});
@@ -1,7 +1,7 @@
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { StreamItem } from "@/types/stream";
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import {
classifySessionTimelineSeq,
type SessionTimelineSeqDecision,
@@ -12,38 +12,25 @@ import {
} from "@/contexts/session-timeline-bootstrap-policy";
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
// ---------------------------------------------------------------------------
// Shared cursor type
// ---------------------------------------------------------------------------
export type TimelineCursor = {
epoch: string;
startSeq: number;
endSeq: number;
};
// ---------------------------------------------------------------------------
// Side-effect discriminated unions
// ---------------------------------------------------------------------------
export type TimelineReducerSideEffect =
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
| { type: "catch_up"; cursor: { endSeq: number } }
| { type: "flush_pending_updates" };
export type AgentStreamReducerSideEffect = {
type: "catch_up";
cursor: { epoch: string; endSeq: number };
cursor: { endSeq: number };
};
// ---------------------------------------------------------------------------
// processTimelineResponse
// ---------------------------------------------------------------------------
type TimelineDirection = "tail" | "before" | "after";
type InitRequestDirection = "tail" | "after";
type TimelineResponseEntry = {
seqStart: number;
seq: number;
provider: string;
item: Record<string, unknown>;
timestamp: string;
@@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput {
payload: {
agentId: string;
direction: TimelineDirection;
reset: boolean;
epoch: string;
startCursor: { seq: number } | null;
endCursor: { seq: number } | null;
startSeq: number | null;
endSeq: number | null;
entries: TimelineResponseEntry[];
error: string | null;
};
@@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput {
sideEffects: TimelineReducerSideEffect[];
}
export function processTimelineResponse(
input: ProcessTimelineResponseInput,
): ProcessTimelineResponseOutput {
const {
payload,
currentTail,
currentHead,
currentCursor,
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
} = input;
// ------------------------------------------------------------------
// Error path: reject init and leave stream state unchanged
// ------------------------------------------------------------------
if (payload.error) {
return {
tail: currentTail,
head: currentHead,
cursor: currentCursor,
cursorChanged: false,
initResolution: hasActiveInitDeferred ? "reject" : null,
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
};
}
// ------------------------------------------------------------------
// Convert entries to timeline units
// ------------------------------------------------------------------
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seqStart,
event: {
type: "timeline",
provider: entry.provider,
item: entry.item,
} as AgentStreamEventPayload,
timestamp: new Date(entry.timestamp),
}));
const toHydratedEvents = (
units: typeof timelineUnits,
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
units.map(({ event, timestamp }) => ({ event, timestamp }));
// ------------------------------------------------------------------
// Derive bootstrap policy (replace vs incremental)
// ------------------------------------------------------------------
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
direction: payload.direction,
reset: payload.reset,
epoch: payload.epoch,
endCursor: payload.endCursor,
isInitializing,
hasActiveInitDeferred,
});
const replace = bootstrapPolicy.replace;
let nextTail = currentTail;
let nextHead = currentHead;
let nextCursor: TimelineCursor | null | undefined = currentCursor;
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
if (replace) {
// ----------------------------------------------------------------
// Replace path: full hydration from scratch
// ----------------------------------------------------------------
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
source: "canonical",
});
nextHead = [];
if (payload.startCursor && payload.endCursor) {
nextCursor = {
epoch: payload.epoch,
startSeq: payload.startCursor.seq,
endSeq: payload.endCursor.seq,
};
cursorChanged = true;
} else {
nextCursor = null;
cursorChanged = true;
}
if (bootstrapPolicy.catchUpCursor) {
sideEffects.push({
type: "catch_up",
cursor: bootstrapPolicy.catchUpCursor,
});
}
} else if (timelineUnits.length > 0) {
// ----------------------------------------------------------------
// Incremental append path
// ----------------------------------------------------------------
const acceptedUnits: typeof timelineUnits = [];
let cursor = currentCursor;
let gapCursor: { epoch: string; endSeq: number } | null = null;
for (const unit of timelineUnits) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
epoch: payload.epoch,
seq: unit.seq,
});
if (decision === "gap") {
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale" || decision === "drop_epoch") {
continue;
}
acceptedUnits.push(unit);
if (decision === "init") {
cursor = {
epoch: payload.epoch,
startSeq: unit.seq,
endSeq: unit.seq,
};
continue;
}
if (!cursor) {
continue;
}
cursor = {
...cursor,
endSeq: unit.seq,
};
}
if (acceptedUnits.length > 0) {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
);
}
if (
cursor &&
(!currentCursor ||
currentCursor.epoch !== cursor.epoch ||
currentCursor.startSeq !== cursor.startSeq ||
currentCursor.endSeq !== cursor.endSeq)
) {
nextCursor = cursor;
cursorChanged = true;
}
if (gapCursor) {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
}
// ------------------------------------------------------------------
// Flush pending agent updates side effect
// ------------------------------------------------------------------
sideEffects.push({ type: "flush_pending_updates" });
// ------------------------------------------------------------------
// Init resolution
// ------------------------------------------------------------------
const shouldResolveDeferredInit = shouldResolveTimelineInit({
hasActiveInitDeferred,
isInitializing,
initRequestDirection,
responseDirection: payload.direction,
reset: payload.reset,
});
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
initResolution,
clearInitializing,
error: null,
sideEffects,
};
}
// ---------------------------------------------------------------------------
// processAgentStreamEvent
// ---------------------------------------------------------------------------
export interface ProcessAgentStreamEventInput {
event: AgentStreamEventPayload;
seq: number | undefined;
epoch: string | undefined;
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
@@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput {
sideEffects: AgentStreamReducerSideEffect[];
}
function cursorsEqual(
left: TimelineCursor | null | undefined,
right: TimelineCursor | null | undefined,
): boolean {
if (!left || !right) {
return left === right;
}
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
}
function removeSupersededProvisionalItems(
head: StreamItem[],
event: AgentStreamEventPayload,
): StreamItem[] {
if (head.length === 0 || event.type !== "timeline") {
return head;
}
let nextHead = head;
if (event.item.type === "assistant_message") {
nextHead = head.filter((item) => item.kind !== "assistant_message");
} else if (event.item.type === "tool_call") {
const committedToolCall = event.item;
nextHead = head.filter(
(item) =>
item.kind !== "tool_call" ||
item.payload.source !== "agent" ||
item.payload.data.callId !== committedToolCall.callId,
);
}
return nextHead.length === head.length ? head : nextHead;
}
export function processTimelineResponse(
input: ProcessTimelineResponseInput,
): ProcessTimelineResponseOutput {
const {
payload,
currentTail,
currentHead,
currentCursor,
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
} = input;
if (payload.error) {
return {
tail: currentTail,
head: currentHead,
cursor: currentCursor,
cursorChanged: false,
initResolution: hasActiveInitDeferred ? "reject" : null,
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
};
}
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seq,
event: {
type: "timeline",
provider: entry.provider,
item: entry.item,
} as AgentStreamEventPayload,
timestamp: new Date(entry.timestamp),
}));
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
direction: payload.direction,
endSeq: payload.endSeq,
isInitializing,
hasActiveInitDeferred,
});
let nextTail = currentTail;
let nextHead = currentHead;
let nextCursor: TimelineCursor | null | undefined = currentCursor;
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
if (bootstrapPolicy.replace) {
nextTail = hydrateStreamState(
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
{ source: "canonical" },
);
nextHead = [];
nextCursor =
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
? {
startSeq: payload.startSeq,
endSeq: payload.endSeq,
}
: null;
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
if (bootstrapPolicy.catchUpCursor) {
sideEffects.push({
type: "catch_up",
cursor: bootstrapPolicy.catchUpCursor,
});
}
} else if (payload.direction === "before") {
const prepended = hydrateStreamState(
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
{ source: "canonical" },
);
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
const derivedCursor =
typeof payload.startSeq === "number"
? {
startSeq: payload.startSeq,
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
}
: currentCursor;
nextCursor = derivedCursor;
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
} else if (timelineUnits.length > 0) {
const acceptedUnits: typeof timelineUnits = [];
let cursor = currentCursor;
let gapCursor: { endSeq: number } | null = null;
for (const unit of timelineUnits) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
cursor: cursor ? { endSeq: cursor.endSeq } : null,
seq: unit.seq,
});
if (decision === "gap") {
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale") {
continue;
}
acceptedUnits.push(unit);
cursor =
decision === "init"
? { startSeq: unit.seq, endSeq: unit.seq }
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
}
if (acceptedUnits.length > 0) {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
);
}
if (cursor && !cursorsEqual(currentCursor, cursor)) {
nextCursor = cursor;
cursorChanged = true;
}
if (gapCursor) {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
}
sideEffects.push({ type: "flush_pending_updates" });
const shouldResolveDeferredInit = shouldResolveTimelineInit({
hasActiveInitDeferred,
isInitializing,
initRequestDirection,
responseDirection: payload.direction,
});
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
initResolution: shouldResolveDeferredInit ? "resolve" : null,
clearInitializing,
error: null,
sideEffects,
};
}
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
let shouldApplyStreamEvent = true;
let nextTail = currentTail;
let nextHead = currentHead;
let changedTail = false;
let changedHead = false;
let nextTimelineCursor: TimelineCursor | null = null;
let cursorChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = [];
// ------------------------------------------------------------------
// Timeline sequencing gate
// ------------------------------------------------------------------
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
if (event.type === "timeline" && typeof seq === "number") {
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
seq,
});
if (decision === "init") {
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
cursorChanged = true;
} else if (decision === "accept") {
nextTimelineCursor = {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
};
cursorChanged = true;
} else if (decision === "gap") {
shouldApplyStreamEvent = false;
if (decision === "gap") {
if (currentCursor) {
sideEffects.push({
type: "catch_up",
cursor: {
epoch: currentCursor.epoch,
endSeq: currentCursor.endSeq,
},
cursor: { endSeq: currentCursor.endSeq },
});
}
} else {
// drop_stale or drop_epoch
shouldApplyStreamEvent = false;
} else if (decision !== "drop_stale") {
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
source: "canonical",
});
changedTail = nextTail !== currentTail;
nextHead = removeSupersededProvisionalItems(currentHead, event);
changedHead = nextHead !== currentHead;
nextTimelineCursor =
decision === "init"
? { startSeq: seq, endSeq: seq }
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
}
} else if (event.type === "timeline") {
nextHead = reduceStreamUpdate(currentHead, event, timestamp, {
source: "live",
});
changedHead = nextHead !== currentHead;
} else if (
(event.type === "turn_completed" ||
event.type === "turn_canceled" ||
event.type === "turn_failed") &&
currentHead.length > 0
) {
nextHead = [];
changedHead = true;
}
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
? applyStreamEvent({
tail: currentTail,
head: currentHead,
event,
timestamp,
source: "live",
})
: {
tail: currentTail,
head: currentHead,
changedTail: false,
changedHead: false,
};
// ------------------------------------------------------------------
// Optimistic lifecycle status
// ------------------------------------------------------------------
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
@@ -402,8 +366,8 @@ export function processAgentStreamEvent(
}
return {
tail,
head,
tail: nextTail,
head: nextHead,
changedTail,
changedHead,
cursor: nextTimelineCursor,
@@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest";
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
import {
deriveBootstrapTailTimelinePolicy,
deriveInitialTimelineRequest,
shouldResolveTimelineInit,
} from "./session-timeline-bootstrap-policy";
describe("deriveBootstrapTailTimelinePolicy", () => {
it("always replaces on explicit reset without catch-up cursor", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "after",
reset: true,
epoch: "epoch-1",
endCursor: { seq: 200 },
isInitializing: false,
hasActiveInitDeferred: false,
describe("deriveInitialTimelineRequest", () => {
it("uses tail bootstrap when history has not synced yet", () => {
expect(
deriveInitialTimelineRequest({
cursor: { seq: 42 },
hasAuthoritativeHistory: false,
initialTimelineLimit: 200,
}),
).toEqual({
direction: "tail",
limit: 200,
});
expect(policy.replace).toBe(true);
expect(policy.catchUpCursor).toBeNull();
});
it("uses catch-up after the committed cursor once history is synced", () => {
expect(
deriveInitialTimelineRequest({
cursor: { seq: 42 },
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
}),
).toEqual({
direction: "after",
cursor: { seq: 42 },
limit: 0,
});
});
});
describe("deriveBootstrapTailTimelinePolicy", () => {
it("forces baseline replace and canonical catch-up for init tail race", () => {
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
const advancedCursor = { endSeq: 205 };
const tailSeqStart = 101;
const tailSeqEnd = 200;
@@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
const decision = classifySessionTimelineSeq({
cursor: advancedCursor,
epoch: "epoch-1",
seq,
});
if (decision === "accept" || decision === "init") {
@@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "tail",
reset: false,
epoch: "epoch-1",
endCursor: { seq: 200 },
endSeq: 200,
isInitializing: true,
hasActiveInitDeferred: true,
});
expect(policy.replace).toBe(true);
expect(policy.catchUpCursor).toEqual({
epoch: "epoch-1",
endSeq: 200,
});
});
@@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
it("does not replace non-bootstrap, non-reset responses", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "tail",
reset: false,
epoch: "epoch-1",
endCursor: { seq: 200 },
endSeq: 200,
isInitializing: false,
hasActiveInitDeferred: false,
});
@@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "tail",
responseDirection: "tail",
reset: false,
}),
).toBe(true);
});
@@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "tail",
responseDirection: "after",
reset: false,
}),
).toBe(false);
});
@@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "after",
responseDirection: "after",
reset: false,
}),
).toBe(true);
});
@@ -6,7 +6,6 @@ type BootstrapTailCursor = {
} | null;
type InitialTimelineCursor = {
epoch: string;
seq: number;
} | null;
@@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({
initialTimelineLimit: number;
}): {
direction: "tail" | "after";
cursor?: { epoch: string; seq: number };
cursor?: { seq: number };
limit: number;
projection: "canonical";
} {
if (!hasAuthoritativeHistory || !cursor) {
return {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
};
}
return {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.seq },
cursor: { seq: cursor.seq },
limit: 0,
projection: "canonical",
};
}
export function deriveBootstrapTailTimelinePolicy({
direction,
reset,
epoch,
endCursor,
endSeq,
isInitializing,
hasActiveInitDeferred,
}: {
direction: TimelineDirection;
reset: boolean;
epoch: string;
endCursor: BootstrapTailCursor;
endSeq: number | null;
isInitializing: boolean;
hasActiveInitDeferred: boolean;
}): {
replace: boolean;
catchUpCursor: { epoch: string; endSeq: number } | null;
catchUpCursor: { endSeq: number } | null;
} {
if (reset) {
return { replace: true, catchUpCursor: null };
}
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
if (!isBootstrapTailInit) {
return { replace: false, catchUpCursor: null };
@@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({
return {
replace: true,
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
};
}
@@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({
isInitializing,
initRequestDirection,
responseDirection,
reset,
}: {
hasActiveInitDeferred: boolean;
isInitializing: boolean;
initRequestDirection: InitRequestDirection;
responseDirection: TimelineDirection;
reset: boolean;
}): boolean {
if (!hasActiveInitDeferred || !isInitializing) {
return false;
}
if (reset) {
return true;
}
return responseDirection === initRequestDirection;
}
@@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => {
it("accepts contiguous forward seq", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-1",
cursor: { endSeq: 4 },
seq: 5,
}),
).toBe("accept");
@@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => {
it("drops stale seq older than the current end", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 8 },
epoch: "epoch-1",
cursor: { endSeq: 8 },
seq: 7,
}),
).toBe("drop_stale");
@@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => {
it("drops duplicate replay seq equal to the current end", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 8 },
epoch: "epoch-1",
cursor: { endSeq: 8 },
seq: 8,
}),
).toBe("drop_stale");
});
it("drops epoch mismatch", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-2",
seq: 5,
}),
).toBe("drop_epoch");
});
it("initializes when cursor is null", () => {
expect(
classifySessionTimelineSeq({
cursor: null,
epoch: "epoch-1",
seq: 1,
}),
).toBe("init");
@@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => {
it("classifies forward gaps", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-1",
cursor: { endSeq: 4 },
seq: 9,
}),
).toBe("gap");
@@ -1,28 +1,22 @@
export type SessionTimelineSeqCursor =
| {
epoch: string;
endSeq: number;
}
| null
| undefined;
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
export function classifySessionTimelineSeq({
cursor,
epoch,
seq,
}: {
cursor: SessionTimelineSeqCursor;
epoch: string;
seq: number;
}): SessionTimelineSeqDecision {
if (!cursor) {
return "init";
}
if (cursor.epoch !== epoch) {
return "drop_epoch";
}
if (seq <= cursor.endSeq) {
return "drop_stale";
}
@@ -12,10 +12,6 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -42,53 +38,46 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(targets.backdropOpacity, {
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = useCallback(() => {
@@ -6,7 +6,7 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStatus = {
serverId: string;
status: DesktopDaemonState;
listen: string | null;
listen: string;
hostname: string | null;
pid: number | null;
home: string;
@@ -82,7 +82,7 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
return {
serverId: toStringOrNull(raw.serverId) ?? "",
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen),
listen: toStringOrNull(raw.listen) ?? "",
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "",
@@ -27,12 +27,3 @@ export async function isDesktopFullscreen(): Promise<boolean> {
}
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
return;
}
await win.setTitleBarTheme(theme);
}
-9
View File
@@ -37,13 +37,6 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopMenuBridge {
showContextMenu?: (input?: {
kind?: "terminal";
hasSelection?: boolean;
}) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
@@ -51,7 +44,6 @@ export interface DesktopWindowBridge {
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
@@ -81,7 +73,6 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
menu?: DesktopMenuBridge;
}
declare global {
@@ -10,15 +10,8 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktop,
status,
availableUpdate,
errorMessage,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } =
useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -40,36 +33,24 @@ export function UpdateBanner() {
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;
if (status !== "available" && status !== "installed") return null;
const isInstalled = status === "installed";
const isError = status === "error";
function getTitle(): string {
if (isInstalled) return "Update installed";
if (isInstalling) return "Installing update";
if (isError) return "Update failed";
return "Update available";
}
function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Downloading and installing...";
if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
}
return (
<View style={styles.container} pointerEvents="box-none">
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.banner}>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.textSection}>
<Text style={styles.title}>{getTitle()}</Text>
<Text style={styles.subtitle}>{getSubtitle()}</Text>
<Text style={styles.title}>{isInstalled ? "Update installed" : "Update available"}</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
</Text>
</View>
<View style={styles.actions}>
@@ -80,7 +61,7 @@ export function UpdateBanner() {
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && !isError && (
{!isInstalled && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
@@ -95,15 +76,6 @@ export function UpdateBanner() {
</Text>
</Pressable>
)}
{isError && (
<Pressable
onPress={() => void checkForUpdates()}
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
)}
</View>
</View>
</View>
@@ -137,21 +109,15 @@ const styles = StyleSheet.create((theme) => ({
},
closeButton: {
position: "absolute",
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
top: theme.spacing[2],
left: theme.spacing[2],
padding: theme.spacing[1],
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
paddingTop: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
+19 -17
View File
@@ -13,7 +13,6 @@ import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
useFormPreferences,
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
@@ -694,23 +693,26 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
const nextPreferences = mergeProviderPreferences({
preferences: preferences ?? {},
provider: formState.provider,
updates: {
model: modelId || undefined,
mode: formState.modeId || undefined,
...(modelId && formState.thinkingOptionId
? {
thinkingByModel: {
[modelId]: formState.thinkingOptionId,
},
}
: {}),
} satisfies Partial<ProviderPreferences>,
});
const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
await updatePreferences(nextPreferences);
const nextProviderPrefs: ProviderPreferences = {
model: modelId || undefined,
mode: formState.modeId || undefined,
thinkingByModel: {
...existingProviderPrefs?.thinkingByModel,
...(modelId && formState.thinkingOptionId
? { [modelId]: formState.thinkingOptionId }
: {}),
},
};
await updatePreferences({
provider: formState.provider,
providerPreferences: {
...preferences?.providerPreferences,
[formState.provider]: nextProviderPrefs,
},
});
}, [
availableModels,
formState.model,
@@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-initialization";
describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when history has not synced yet", () => {
it("uses committed tail bootstrap when history has not synced yet", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: false,
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical tail bootstrap when cursor is missing", () => {
it("uses committed tail bootstrap when cursor is missing", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: null,
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor once history is synced", () => {
it("uses committed catch-up after the current cursor once history is synced", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: true,
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
}),
).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
cursor: { seq: 42 },
limit: 0,
projection: "canonical",
});
});
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 0,
projection: "canonical",
});
});
@@ -60,7 +60,7 @@ export function useAgentInitialization({
const hasAuthoritativeHistory =
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest = deriveInitialTimelineRequest({
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
cursor: cursor ? { seq: cursor.endSeq } : null,
hasAuthoritativeHistory,
initialTimelineLimit,
});
@@ -107,7 +107,6 @@ export function useAgentInitialization({
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
});
} catch (error) {
setAgentInitializing(agentId, false);
@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { mergeProviderPreferences } from "./use-form-preferences";
describe("mergeProviderPreferences", () => {
it("stores the selected model for a provider", () => {
expect(
mergeProviderPreferences({
preferences: {},
provider: "claude",
updates: { model: "claude-opus-4-6" },
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-opus-4-6",
},
},
});
});
it("merges thinking preferences by model without dropping existing entries", () => {
expect(
mergeProviderPreferences({
preferences: {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
},
},
},
},
provider: "claude",
updates: {
thinkingByModel: {
"claude-opus-4-6": "high",
},
},
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
"claude-opus-4-6": "high",
},
},
},
});
});
});
@@ -2,7 +2,6 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
@@ -36,36 +35,6 @@ export interface UseFormPreferencesReturn {
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
},
},
};
}
export function useFormPreferences(): UseFormPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
@@ -30,16 +30,6 @@ describe("use-settings", () => {
);
});
it("defaults theme to auto when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.theme).toBe("auto");
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface AppSettings {
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
theme: "dark",
manageBuiltInDaemon: true,
};
+16 -166
View File
@@ -15,10 +15,7 @@ import {
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import {
shouldUseDesktopDaemon,
startDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { getOrCreateClientId } from "@/utils/client-id";
@@ -36,10 +33,6 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRuntimeBootstrapResult =
| { ok: true; listenAddress: string; serverId: string; hostname: string | null }
| { ok: false; error: string };
export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
@@ -1080,7 +1073,6 @@ const DEFAULT_LOCALHOST_ENDPOINT =
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const E2E_STORAGE_KEY = "@paseo:e2e";
export class HostRuntimeStore {
@@ -1145,7 +1137,7 @@ export class HostRuntimeStore {
}
}
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
async bootstrap(): Promise<void> {
if (this.bootstrapAttempted) {
return;
}
@@ -1161,48 +1153,30 @@ export class HostRuntimeStore {
}
if (shouldUseDesktopDaemon()) {
if (options?.manageBuiltInDaemon ?? true) {
await this.bootstrapDesktop();
}
await this.bootstrapDesktop();
} else {
await this.bootstrapLocalhost();
}
}
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
private async bootstrapDesktop(): Promise<void> {
try {
const daemon = await startDesktopDaemon();
const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim();
if (!listenAddress) {
return {
ok: false,
error: "Desktop daemon did not return a listen address.",
};
const connection = connectionFromListen(daemon.listen);
if (!connection) {
return;
}
if (!serverId) {
return {
ok: false,
error: "Desktop daemon did not return a server id.",
};
}
if (!connectionFromListen(listenAddress)) {
return {
ok: false,
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
};
}
return {
ok: true,
listenAddress,
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({
serverId,
hostname: daemon.hostname,
};
label: hostname ?? daemon.hostname ?? undefined,
connection,
existingClient: client,
});
} catch (error) {
return {
ok: false,
error: toErrorMessage(error),
};
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
}
}
@@ -1308,36 +1282,6 @@ export class HostRuntimeStore {
return this.upsertConnectionFromOffer(offer);
}
async addConnectionFromListenAndWaitForOnline(input: {
listenAddress: string;
serverId: string;
hostname: string | null;
timeoutMs?: number;
}): Promise<HostProfile> {
const normalizedListenAddress = input.listenAddress.trim();
const serverId = input.serverId.trim();
const connection = connectionFromListen(normalizedListenAddress);
if (!connection) {
throw new Error(`Unsupported listen address: ${input.listenAddress}`);
}
if (!serverId) {
throw new Error("Desktop daemon did not return a server id.");
}
const profile = await this.upsertHostConnection({
serverId,
label: input.hostname ?? undefined,
connection,
});
await this.waitForConnectionOnline({
serverId,
connectionId: connection.id,
timeoutMs: input.timeoutMs,
});
return profile;
}
async renameHost(serverId: string, label: string): Promise<void> {
const next = this.hosts.map((h) =>
h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h,
@@ -1499,100 +1443,6 @@ export class HostRuntimeStore {
}
}
private waitForConnectionOnline(input: {
serverId: string;
connectionId: string;
timeoutMs?: number;
}): Promise<void> {
const { serverId, connectionId } = input;
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const cleanup = (unsubscribe: (() => void) | null): void => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
unsubscribe?.();
};
const settle = (
unsubscribe: (() => void) | null,
outcome: { ok: true } | { ok: false; error: Error },
): void => {
if (settled) {
return;
}
settled = true;
cleanup(unsubscribe);
if (outcome.ok) {
resolve();
} else {
reject(outcome.error);
}
};
const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => {
const snapshot = this.getSnapshot(serverId);
if (!snapshot) {
return {
ok: false,
error: new Error(`Unknown host runtime for serverId ${serverId}`),
};
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "online"
) {
return { ok: true };
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "error"
) {
return {
ok: false,
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
};
}
return null;
};
const unsubscribe = this.subscribe(serverId, () => {
const outcome = readSnapshot();
if (outcome) {
settle(unsubscribe, outcome);
}
});
timeoutHandle = setTimeout(() => {
settle(unsubscribe, {
ok: false,
error: new Error(`Timed out waiting for connection ${connectionId} to come online.`),
});
}, timeoutMs);
const initialOutcome = readSnapshot();
if (initialOutcome) {
settle(unsubscribe, initialOutcome);
return;
}
void this.runProbeCycleNow(serverId).catch((error) => {
settle(unsubscribe, {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
});
});
});
}
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
const controller = this.controllers.get(serverId);
if (!controller) {
@@ -1,22 +1,26 @@
import { useEffect } from "react";
import { View, Text } from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { MenuHeader } from "@/components/headers/menu-header";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { getIsDesktopMac } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const trafficLightPadding = useTrafficLightPadding();
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
const openAgentList = usePanelStore((s) => s.openAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
@@ -27,24 +31,22 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
return (
<View style={styles.container} {...dragHandlers}>
<MenuHeader borderless />
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle />
</View>
<View style={styles.content}>
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>
<View style={styles.headingGroup}>
<Text style={styles.heading}>What shall we build today?</Text>
{hasHydrated && !hasProjects ? (
<Text style={styles.subtitle}>
Add a project folder to start running agents on your codebase
</Text>
) : null}
</View>
<View style={styles.cta}>
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
Add a project
</Button>
</View>
<PaseoLogo size={56} />
<Text style={styles.heading}>What shall we build today?</Text>
<Pressable
style={({ hovered }) => [styles.openButton, hovered && styles.openButtonHovered]}
onPress={() => {
void openProjectPicker();
}}
testID="open-project-submit"
>
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.openButtonText}>Add a project</Text>
</Pressable>
</View>
</View>
);
@@ -56,32 +58,43 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
menuToggle: {
position: "absolute",
top: theme.spacing[3],
left: theme.spacing[3],
zIndex: 1,
},
content: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
gap: 0,
gap: theme.spacing[6],
padding: theme.spacing[6],
},
logo: {
marginBottom: theme.spacing[8],
},
headingGroup: {
alignItems: "center",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
},
heading: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
subtitle: {
openButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: "transparent",
},
openButtonHovered: {
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
openButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
+8 -36
View File
@@ -1,14 +1,10 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { AgentList } from "@/components/agent-list";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
const isFocused = useIsFocused();
@@ -48,26 +44,13 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title="Sessions" />
{sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No sessions yet</Text>
<Button
variant="ghost"
leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
>
Back
</Button>
</View>
) : (
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
)}
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
</View>
);
}
@@ -77,15 +60,4 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[6],
padding: theme.spacing[6],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
},
}));
@@ -896,6 +896,12 @@ export default function SettingsScreen() {
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
if (newTheme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(newTheme);
}
},
[updateSettings],
);
@@ -1,318 +1,29 @@
import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import {
getDesktopDaemonLogs,
type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
type StartupSplashScreenProps = {
bootstrapState?: {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
};
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const DOCS_URL = "https://paseo.sh/docs";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[8],
paddingVertical: theme.spacing[8],
},
containerError: {
justifyContent: "flex-start",
paddingTop: theme.spacing[16],
},
centeredContent: {
alignItems: "center",
justifyContent: "center",
maxWidth: 520,
width: "100%",
},
errorContent: {
alignItems: "stretch",
maxWidth: 720,
width: "100%",
gap: theme.spacing[6],
},
errorHeader: {
alignItems: "flex-start",
},
title: {
marginTop: theme.spacing[8],
color: theme.colors.foreground,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
titleError: {
textAlign: "left",
},
subtitleRow: {
marginTop: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
progressSteps: {
marginTop: theme.spacing[4],
gap: theme.spacing[3],
width: "100%",
},
progressStepRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
},
subtitle: {
status: {
marginTop: theme.spacing[8],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
subtitleInline: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
errorDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
errorMessage: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontFamily: Fonts.mono,
},
logsMeta: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
logsContainer: {
height: 200,
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
logsScroll: {
flexGrow: 0,
},
logsContent: {
padding: theme.spacing[4],
},
logsText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
actionRow: {
flexDirection: "row",
gap: theme.spacing[3],
flexWrap: "wrap",
},
}));
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
export function StartupSplashScreen() {
const dragHandlers = useDesktopDragHandlers();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const phase = bootstrapState?.phase;
const isError = phase === "error";
const isSimpleSplash = bootstrapState === undefined;
useEffect(() => {
if (!isError) {
setDaemonLogs(null);
setLogsError(null);
setIsLoadingLogs(false);
return;
}
let isCancelled = false;
setIsLoadingLogs(true);
setLogsError(null);
void getDesktopDaemonLogs()
.then((logs) => {
if (isCancelled) {
return;
}
setDaemonLogs(logs);
})
.catch((error) => {
if (isCancelled) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setDaemonLogs(null);
setLogsError(`Unable to load daemon logs: ${message}`);
})
.finally(() => {
if (!isCancelled) {
setIsLoadingLogs(false);
}
});
return () => {
isCancelled = true;
};
}, [isError]);
const progressSteps =
phase === "starting-daemon"
? [{ key: "starting-daemon", label: "Starting local server...", status: "active" as const }]
: phase === "connecting"
? [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connecting to local server...", status: "active" as const },
]
: [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connected to local server", status: "complete" as const },
];
const logsText = useMemo(() => {
if (isLoadingLogs) {
return "Loading daemon logs...";
}
if (daemonLogs?.contents) {
return daemonLogs.contents;
}
if (logsError) {
return logsError;
}
return "No daemon logs available.";
}, [daemonLogs?.contents, isLoadingLogs, logsError]);
const handleCopyLogs = () => {
const payload = daemonLogs?.logPath
? `${daemonLogs.logPath}\n\n${daemonLogs.contents}`
: logsText;
void Clipboard.setStringAsync(payload);
};
if (isSimpleSplash) {
return (
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Starting up…</Text>
</View>
);
}
if (!isError) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.centeredContent}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<View style={styles.progressSteps}>
{progressSteps.map((step) => (
<View key={step.key} style={styles.progressStepRow}>
{step.status === "complete" ? (
<Check size={18} color={theme.colors.success} />
) : (
<ActivityIndicator color={theme.colors.accent} />
)}
<Text style={styles.subtitleInline}>{step.label}</Text>
</View>
))}
</View>
</View>
</View>
);
}
return (
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
</View>
<Text style={styles.errorDescription}>
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
</Text>
<Text style={styles.errorMessage}>
{bootstrapState.error}
</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
</View>
<View style={styles.actionRow}>
<Button
variant="secondary"
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
onPress={handleCopyLogs}
>
Copy logs
</Button>
<Button
variant="outline"
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
>
Open GitHub issue
</Button>
<Button
variant="outline"
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(DOCS_URL)}
>
Docs
</Button>
<Button
variant="default"
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
onPress={bootstrapState.retry}
>
Retry
</Button>
</View>
</View>
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up…</Text>
</View>
);
}
-1
View File
@@ -191,7 +191,6 @@ export type DaemonServerInfo = {
};
export interface AgentTimelineCursorState {
epoch: string;
startSeq: number;
endSeq: number;
}
@@ -8,7 +8,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -169,12 +168,7 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void openExternalUrl(uri);
}),
);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {
+6 -20
View File
@@ -2,11 +2,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import {
getIsDesktopMac,
getIsDesktop,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
DESKTOP_WINDOW_CONTROLS_WIDTH,
DESKTOP_WINDOW_CONTROLS_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
@@ -123,11 +120,11 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
}, [isActive]);
}
export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } {
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktop()) return;
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
@@ -178,23 +175,12 @@ export function useTrafficLightPadding(): { left: number; right: number; top: nu
};
}, []);
if (!getIsDesktop() || isFullscreen) {
return { left: 0, right: 0, top: 0, side: null };
}
if (getIsDesktopMac()) {
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
right: 0,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
side: 'left',
};
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 };
}
return {
left: 0,
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
side: 'right',
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
describe("sidebar-animation-state", () => {
it("requests a sync when the open state changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: true,
previousWindowWidth: 390,
nextWindowWidth: 390,
}),
).toBe(true);
});
it("requests a sync when the viewport width changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: false,
previousWindowWidth: 390,
nextWindowWidth: 430,
}),
).toBe(true);
});
it("keeps the left sidebar fully off-screen when closed", () => {
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: -430,
backdropOpacity: 0,
});
});
it("keeps the right sidebar fully off-screen when closed", () => {
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: 430,
backdropOpacity: 0,
});
});
});
@@ -1,41 +0,0 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen ||
input.previousWindowWidth !== input.nextWindowWidth
);
}
export function getLeftSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : -input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function getRightSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.38",
"version": "0.1.33",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.38",
"@getpaseo/server": "0.1.38",
"@getpaseo/relay": "0.1.33",
"@getpaseo/server": "0.1.33",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
-12
View File
@@ -2,11 +2,8 @@ import { Command } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
@@ -140,15 +137,6 @@ export function createCli(): Command {
// Daemon commands
program.addCommand(createDaemonCommand());
// Chat commands
program.addCommand(createChatCommand());
// Loop commands
program.addCommand(createLoopCommand());
// Schedule commands
program.addCommand(createScheduleCommand());
// Permission commands
program.addCommand(createPermitCommand());
+4 -2
View File
@@ -1,6 +1,5 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command {
return cmd
@@ -70,9 +69,12 @@ export async function runDeleteCommand(
if (options.all) {
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
return isSameOrDescendantPath(options.cwd!, a.cwd);
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);
+5 -2
View File
@@ -3,7 +3,6 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addLsOptions(cmd: Command): Command {
return cmd
@@ -194,7 +193,11 @@ export async function runLsCommand(
// Optional cwd filter.
if (options.cwd) {
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
}
// Apply label filtering only when explicitly requested.
-1
View File
@@ -193,7 +193,6 @@ export async function resolveStructuredResponseMessage(options: {
try {
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
direction: "tail",
projection: "projected",
limit: 200,
});
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
+4 -2
View File
@@ -1,6 +1,5 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type {
CommandOptions,
SingleResult,
@@ -76,9 +75,12 @@ export async function runStopCommand(
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
return isSameOrDescendantPath(options.cwd!, a.cwd);
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
// Stop specific agent
-31
View File
@@ -1,31 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export interface ChatCreateOptions extends ChatCommandOptions {
purpose?: string;
}
export async function runCreateCommand(
name: string,
options: ChatCreateOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.createChatRoom({
name,
purpose: options.purpose,
});
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-24
View File
@@ -1,24 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runDeleteCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.deleteChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-69
View File
@@ -1,69 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runDeleteCommand } from "./delete.js";
import { runPostCommand } from "./post.js";
import { runReadCommand } from "./read.js";
import { runWaitCommand } from "./wait.js";
export function createChatCommand(): Command {
const chat = new Command("chat").description("Manage chat rooms for agent coordination");
addJsonAndDaemonHostOptions(
chat
.command("create")
.description("Create a chat room")
.argument("<name>", "Room name (must be unique)")
.option("--purpose <text>", "Room purpose/description"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
chat.command("ls").description("List chat rooms"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
chat
.command("inspect")
.description("Inspect a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
chat
.command("delete")
.description("Delete a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
chat
.command("post")
.description("Post a chat message")
.argument("<name-or-id>", "Room name or ID")
.argument("<message>", "Message body")
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
).action(withOutput(runPostCommand));
addJsonAndDaemonHostOptions(
chat
.command("read")
.description("Read chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--limit <n>", "Maximum number of messages to return")
.option("--since <duration-or-timestamp>", "Filter by relative duration or ISO timestamp")
.option("--agent <agent-id>", "Filter by author agent ID"),
).action(withOutput(runReadCommand));
addJsonAndDaemonHostOptions(
chat
.command("wait")
.description("Wait for new chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--timeout <duration>", "Maximum wait time"),
).action(withOutput(runWaitCommand));
return chat;
}
-24
View File
@@ -1,24 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runInspectCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.inspectChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-23
View File
@@ -1,23 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runLsCommand(
options: ChatCommandOptions,
_command: Command,
): Promise<ListResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.listChatRooms();
return {
type: "list",
data: payload.rooms.map(toChatRoomRow),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err);
} finally {
await client.close().catch(() => {});
}
}
-41
View File
@@ -1,41 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
resolveChatAuthorAgentId,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatPostOptions extends ChatCommandOptions {
replyTo?: string;
}
export async function runPostCommand(
room: string,
body: string,
options: ChatPostOptions,
_command: Command,
): Promise<SingleResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.postChatMessage({
room,
body,
authorAgentId: resolveChatAuthorAgentId(),
replyToMessageId: options.replyTo,
});
const [message] = await attachAgentNamesToMessages(client, [toChatMessageRow(payload.message!)]);
return {
type: "single",
data: message!,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_POST_FAILED", "post chat message", err);
} finally {
await client.close().catch(() => {});
}
}
-60
View File
@@ -1,60 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseSinceValue,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatReadOptions extends ChatCommandOptions {
limit?: string;
since?: string;
agent?: string;
}
function parseLimit(value?: string): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
throw {
code: "INVALID_LIMIT",
message: "Invalid --limit value",
details: "Use a non-negative integer.",
};
}
return parsed;
}
export async function runReadCommand(
room: string,
options: ChatReadOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.readChatMessages({
room,
limit: parseLimit(options.limit),
since: parseSinceValue(options.since),
authorAgentId: options.agent,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_READ_FAILED", "read chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
-126
View File
@@ -1,126 +0,0 @@
import type { OutputSchema } from "../../output/index.js";
import type { AnyCommandResult, OutputOptions } from "../../output/index.js";
export interface ChatRoomRow {
name: string;
id: string;
purpose: string;
messages: number;
lastMessageAt: string;
}
export interface ChatMessageRow {
id: string;
author: string;
authorName: string | null;
createdAt: string;
replyTo: string;
mentionAgentIds: string[];
mentionLabels: string[];
body: string;
}
export const chatRoomSchema: OutputSchema<ChatRoomRow> = {
idField: "id",
columns: [
{ header: "NAME", field: "name", width: 22 },
{ header: "ID", field: "id", width: 36 },
{ header: "PURPOSE", field: "purpose", width: 30 },
{ header: "MESSAGES", field: "messages", width: 10, align: "right" },
{ header: "LAST MESSAGE", field: "lastMessageAt", width: 24 },
],
};
export const chatMessageSchema: OutputSchema<ChatMessageRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 36 },
{ header: "AUTHOR", field: "author", width: 16 },
{ header: "AUTHOR NAME", field: (row) => row.authorName ?? "-", width: 20 },
{ header: "CREATED", field: "createdAt", width: 24 },
{ header: "REPLY TO", field: "replyTo", width: 36 },
{ header: "MENTIONS", field: (row) => row.mentionLabels.join(", ") || "-", width: 24 },
{ header: "MESSAGE", field: "body", width: 60 },
],
renderHuman: renderChatTranscript,
};
function renderChatTranscript(
result: AnyCommandResult<ChatMessageRow>,
_options: OutputOptions,
): string {
const data = result.type === "list" ? result.data : [result.data];
if (data.length === 0) {
return "";
}
return data.map(renderChatMessageBlock).join("\n\n");
}
function renderChatMessageBlock(message: ChatMessageRow): string {
const authorLabel = message.authorName
? `${message.authorName} (${message.author})`
: message.author;
const lines = [`┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`];
if (message.replyTo !== "-") {
lines.push(`│ reply-to: msg ${message.replyTo}`);
}
if (message.mentionLabels.length > 0) {
lines.push(`│ mentions: ${message.mentionLabels.join(", ")}`);
}
lines.push("│");
const bodyLines = message.body.split(/\r?\n/);
for (const line of bodyLines) {
lines.push(`│ ${line}`);
}
lines.push("│");
lines.push("└─");
return lines.join("\n");
}
function formatTimestamp(input: string): string {
const date = new Date(input);
if (Number.isNaN(date.getTime())) {
return input;
}
return date.toISOString().replace("T", " ").replace(".000Z", "Z");
}
export function toChatRoomRow(room: {
id: string;
name: string;
purpose: string | null;
messageCount: number;
lastMessageAt: string | null;
}): ChatRoomRow {
return {
id: room.id,
name: room.name,
purpose: room.purpose ?? "-",
messages: room.messageCount,
lastMessageAt: room.lastMessageAt ?? "-",
};
}
export function toChatMessageRow(message: {
id: string;
authorAgentId: string;
createdAt: string;
replyToMessageId: string | null;
mentionAgentIds: string[];
body: string;
}): ChatMessageRow {
return {
id: message.id,
author: message.authorAgentId,
authorName: null,
createdAt: message.createdAt,
replyTo: message.replyToMessageId ?? "-",
mentionAgentIds: message.mentionAgentIds,
mentionLabels: message.mentionAgentIds,
body: message.body,
};
}
-127
View File
@@ -1,127 +0,0 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { parseDuration } from "../../utils/duration.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { ChatMessageRow } from "./schema.js";
export interface ChatCommandOptions extends CommandOptions {
host?: string;
}
export async function connectChatClient(host?: string) {
const daemonHost = getDaemonHost({ host });
try {
const client = await connectToDaemon({ host });
return { client, daemonHost };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
}
export async function attachAgentNamesToMessages(
client: Awaited<ReturnType<typeof connectToDaemon>>,
messages: ChatMessageRow[],
): Promise<ChatMessageRow[]> {
const agentIds = new Set<string>();
for (const message of messages) {
agentIds.add(message.author);
for (const mentionId of message.mentionAgentIds) {
agentIds.add(mentionId);
}
}
if (agentIds.size === 0) {
return messages;
}
const payload = await client.fetchAgents({
filter: { includeArchived: true },
});
const agentNames = new Map<string, string>();
for (const entry of payload.entries) {
const title = entry.agent.title?.trim();
if (title) {
agentNames.set(entry.agent.id, title);
}
}
return messages.map((message) => ({
...message,
authorName: agentNames.get(message.author) ?? null,
mentionLabels: message.mentionAgentIds.map((agentId) => {
const name = agentNames.get(agentId);
return name ? `${name} (${agentId})` : agentId;
}),
}));
}
export function toChatCommandError(code: string, action: string, err: unknown): CommandError {
if (err && typeof err === "object" && "code" in err && "message" in err) {
return err as CommandError;
}
const message = err instanceof Error ? err.message : String(err);
const rpcCode =
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
? err.code
: undefined;
return {
code: rpcCode ?? code,
message: `Failed to ${action}: ${message}`,
};
}
export function parseSinceValue(input?: string): string | undefined {
if (!input) {
return undefined;
}
try {
const durationMs = parseDuration(input);
return new Date(Date.now() - durationMs).toISOString();
} catch {
const timestamp = new Date(input);
if (Number.isNaN(timestamp.getTime())) {
const error: CommandError = {
code: "INVALID_SINCE",
message: "Invalid --since value",
details: "Use a duration like 10m or an ISO timestamp.",
};
throw error;
}
return timestamp.toISOString();
}
}
export function parseTimeoutMs(input?: string): number | undefined {
if (!input) {
return undefined;
}
try {
const timeoutMs = parseDuration(input);
if (timeoutMs <= 0) {
throw new Error("Timeout must be positive");
}
return timeoutMs;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: "Invalid timeout value",
details: message,
};
throw error;
}
}
export function resolveChatAuthorAgentId(): string {
const agentId = process.env.PASEO_AGENT_ID?.trim();
return agentId && agentId.length > 0 ? agentId : "manual";
}
-47
View File
@@ -1,47 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseTimeoutMs,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatWaitOptions extends ChatCommandOptions {
timeout?: string;
}
export async function runWaitCommand(
room: string,
options: ChatWaitOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const latest = await client.readChatMessages({
room,
limit: 1,
});
const afterMessageId = latest.messages[0]?.id;
const payload = await client.waitForChatMessages({
room,
afterMessageId,
timeoutMs: parseTimeoutMs(options.timeout),
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
@@ -124,16 +124,11 @@ function resolveDaemonRunnerEntry(): string {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
if (packageJson.name === "@getpaseo/server") {
const distRunner = path.join(
currentDir,
"dist",
"scripts",
"supervisor-entrypoint.js",
);
const distRunner = path.join(currentDir, "dist", "scripts", "daemon-runner.js");
if (existsSync(distRunner)) {
return distRunner;
}
return path.join(currentDir, "scripts", "supervisor-entrypoint.ts");
return path.join(currentDir, "scripts", "daemon-runner.ts");
}
} catch {
// Continue searching up if package.json exists but is invalid.
@@ -299,8 +294,7 @@ export function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
normalized.startsWith("\\\\.\\pipe\\")
) {
return null;
}
@@ -1,5 +1,4 @@
import { spawnSync } from "node:child_process";
import { platform } from "node:os";
export interface NodePathFromPidResult {
nodePath: string | null;
@@ -13,14 +12,17 @@ function normalizeError(error: unknown): string {
return String(error);
}
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
return {
nodePath: null,
error: `ps failed: ${normalizeError(result.error)}`,
};
}
if ((result.status ?? 1) !== 0) {
@@ -32,82 +34,12 @@ function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
}
const resolved = result.stdout.trim();
return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
}
function runProcessProbe(command: string, args: string[]): {
resolved: string | null;
error?: string;
} {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim();
if (!resolved) {
return {
resolved: null,
error: details ? `${command} failed: ${details}` : `${command} exited with code ${result.status ?? 1}`,
nodePath: null,
error: "ps returned an empty command path",
};
}
const resolved = result.stdout.trim();
return resolved ? { resolved } : { resolved: null, error: `${command} returned no executable path` };
}
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const probes: Array<{ label: string; command: string; args: string[]; parseValue?: (stdout: string) => string | null }> = [
{
label: "powershell-cim",
command: "powershell",
args: [
"-NoProfile",
"-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").ExecutablePath`,
],
},
{
label: "powershell-process",
command: "powershell",
args: ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).Path`],
},
{
label: "wmic",
command: "wmic",
args: ["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
parseValue: (stdout) => {
const match = stdout.match(/ExecutablePath=(.+)/);
return match?.[1]?.trim() ?? null;
},
},
];
const errors: string[] = [];
for (const probe of probes) {
const result = runProcessProbe(probe.command, probe.args);
if (result.resolved) {
const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved;
if (resolved) {
return { nodePath: resolved };
}
errors.push(`${probe.label} returned no executable path`);
continue;
}
if (result.error) {
errors.push(`${probe.label}: ${result.error}`);
}
}
return { nodePath: null, error: errors.join("; ") || "could not resolve executable path from PID" };
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
return platform() === "win32"
? resolveNodePathFromPidWindows(pid)
: resolveNodePathFromPidUnix(pid);
return { nodePath: resolved };
}
+24 -55
View File
@@ -15,8 +15,7 @@ interface ProviderBinaryStatus {
interface DaemonStatus {
serverId: string | null;
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
connectedDaemon: "reachable" | "unreachable" | "not_probed";
status: "running" | "stopped" | "unresponsive";
home: string;
listen: string;
hostname: string | null;
@@ -86,16 +85,11 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === "Local Daemon") {
if (item.key === "Status") {
if (item.value === "running") return "green";
if (item.value === "unresponsive") return "yellow";
return "red";
}
if (item.key === "Connected Daemon") {
if (item.value === "reachable") return "green";
if (item.value === "not_probed") return "yellow";
return "red";
}
if (item.key.startsWith(" ")) {
if (item.value === "not found") return "red";
if (item.value.endsWith("(--version failed)")) return "yellow";
@@ -112,8 +106,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [
{ key: "Server ID", value: status.serverId ?? "-" },
{ key: "Local Daemon", value: status.localDaemon },
{ key: "Connected Daemon", value: status.connectedDaemon },
{ key: "Status", value: status.status },
{ key: "Home", value: status.home },
{ key: "Listen", value: status.listen },
{ key: "Hostname", value: status.hostname ?? "-" },
@@ -206,7 +199,6 @@ export async function runStatusCommand(
): Promise<StatusResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode: string;
@@ -219,60 +211,38 @@ export async function runStatusCommand(
daemonNode = "unknown (no PID available)";
}
const cliNode = process.execPath;
let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped";
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let status: DaemonStatus["status"] = state.running ? "running" : "stopped";
let runningAgents: number | null = null;
let idleAgents: number | null = null;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
localDaemon = "stale_pid";
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
}
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
connectedDaemon = "reachable";
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
runningAgents = agents.filter((a) => a.status === "running").length;
idleAgents = agents.filter((a) => a.status === "idle").length;
if (!state.running) {
daemonNode = "unknown (API reachable, PID unresolved)";
note = appendNote(
note,
state.pidInfo
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
);
if (state.running) {
const host = resolveTcpHostFromListen(state.listen);
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
runningAgents = agents.filter((a) => a.status === "running").length;
idleAgents = agents.filter((a) => a.status === "idle").length;
} catch {
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`);
} finally {
await client.close().catch(() => {});
}
} catch {
if (state.running) {
localDaemon = "unresponsive";
}
note = appendNote(
note,
state.running
? `Local daemon PID is running but API requests to ${host} failed`
: `Connected daemon websocket is reachable at ${host} but fetch_agents failed`,
);
} finally {
await client.close().catch(() => {});
} else {
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`);
}
} else if (state.running) {
connectedDaemon = "unreachable";
localDaemon = "unresponsive";
note = appendNote(
note,
`Local daemon PID is running but websocket at ${host} is not reachable`,
);
} else {
connectedDaemon = "unreachable";
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
} else {
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
const cliVersion = resolveCliVersion();
@@ -288,8 +258,7 @@ export async function runStatusCommand(
const daemonStatus: DaemonStatus = {
serverId,
localDaemon,
connectedDaemon,
status,
home: state.home,
listen: state.listen,
hostname: state.pidInfo?.hostname ?? null,
-34
View File
@@ -1,34 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addDaemonHostOption } from "../../utils/command-options.js";
import { addLoopRunOptions, runLoopRunCommand } from "./run.js";
import { addLoopLsOptions, runLoopLsCommand } from "./ls.js";
import { addLoopInspectOptions, runLoopInspectCommand } from "./inspect.js";
import { addLoopLogsOptions, runLoopLogsCommand } from "./logs.js";
import { addLoopStopOptions, runLoopStopCommand } from "./stop.js";
export function createLoopCommand(): Command {
const loop = new Command("loop").description("Run iterative worker loops");
addJsonAndDaemonHostOptions(
addLoopRunOptions(loop.command("run")),
).action(withOutput(runLoopRunCommand));
addJsonAndDaemonHostOptions(
addLoopLsOptions(loop.command("ls")),
).action(withOutput(runLoopLsCommand));
addJsonAndDaemonHostOptions(
addLoopInspectOptions(loop.command("inspect")),
).action(withOutput(runLoopInspectCommand));
addDaemonHostOption(
addLoopLogsOptions(loop.command("logs")),
).action(runLoopLogsCommand);
addJsonAndDaemonHostOptions(
addLoopStopOptions(loop.command("stop")),
).action(withOutput(runLoopStopCommand));
return loop;
}
-118
View File
@@ -1,118 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
ListResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface InspectRow {
key: string;
value: string;
}
export interface LoopInspectOptions extends CommandOptions {}
export function addLoopInspectOptions(command: Command): Command {
return command.description("Show loop details and iteration history").argument("<id>", "Loop ID");
}
function createInspectSchema(loop: LoopRecord): OutputSchema<InspectRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key", width: 18 },
{ header: "VALUE", field: "value", width: 80 },
],
serialize: () => loop,
};
}
function toRows(loop: LoopRecord): InspectRow[] {
return [
{ key: "Id", value: loop.id },
{ key: "Name", value: loop.name ?? "null" },
{ key: "Status", value: loop.status },
{ key: "Cwd", value: loop.cwd },
{ key: "Provider", value: loop.provider },
{ key: "Model", value: loop.model ?? "null" },
{ key: "WorkerProvider", value: loop.workerProvider ?? "null" },
{ key: "WorkerModel", value: loop.workerModel ?? "null" },
{ key: "VerifierProvider", value: loop.verifierProvider ?? "null" },
{ key: "VerifierModel", value: loop.verifierModel ?? "null" },
{ key: "Prompt", value: loop.prompt },
{ key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" },
{ key: "VerifyChecks", value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]" },
{ key: "Archive", value: String(loop.archive) },
{ key: "SleepMs", value: String(loop.sleepMs) },
{ key: "MaxIterations", value: loop.maxIterations === null ? "null" : String(loop.maxIterations) },
{ key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) },
{ key: "CreatedAt", value: loop.createdAt },
{ key: "UpdatedAt", value: loop.updatedAt },
{ key: "CompletedAt", value: loop.completedAt ?? "null" },
{
key: "Iterations",
value:
loop.iterations.length === 0
? "[]"
: loop.iterations
.map((iteration) => {
const summary = [
`#${iteration.index}`,
iteration.status,
iteration.workerAgentId ? `worker=${iteration.workerAgentId}` : null,
iteration.verifierAgentId ? `verifier=${iteration.verifierAgentId}` : null,
iteration.failureReason ? `reason=${iteration.failureReason}` : null,
]
.filter(Boolean)
.join(" ");
return summary;
})
.join(" | "),
},
];
}
export type LoopInspectResult = ListResult<InspectRow>;
export async function runLoopInspectCommand(
id: string,
options: LoopInspectOptions,
_command: Command,
): Promise<LoopInspectResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopInspect(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "list",
data: toRows(payload.loop),
schema: createInspectSchema(payload.loop),
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_INSPECT_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-82
View File
@@ -1,82 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { LoopDaemonClient, LoopLogEntry } from "./types.js";
export interface LoopLogsOptions extends CommandOptions {
pollInterval?: string;
}
export function addLoopLogsOptions(command: Command): Command {
return command
.description("Stream loop logs")
.argument("<id>", "Loop ID")
.option("--poll-interval <ms>", "Polling interval in milliseconds", "1000");
}
function parsePollInterval(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_POLL_INTERVAL",
message: "--poll-interval must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
function renderLogEntry(entry: LoopLogEntry): string {
const prefix = [
entry.timestamp,
entry.source,
entry.iteration === null ? null : `iteration=${entry.iteration}`,
entry.level === "error" ? "ERROR" : null,
]
.filter(Boolean)
.join(" ");
return `${prefix}\n${entry.text}`;
}
export async function runLoopLogsCommand(
id: string,
options: LoopLogsOptions,
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host as string | undefined });
const pollInterval = parsePollInterval(options.pollInterval ?? "1000");
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
let cursor = 0;
try {
for (;;) {
const payload = await client.loopLogs(id, cursor);
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
cursor = payload.nextCursor;
for (const entry of payload.entries) {
console.log(renderLogEntry(entry));
}
if (payload.loop.status !== "running") {
await client.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
} catch (error) {
await client.close().catch(() => {});
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Failed to stream loop logs: ${message}`);
process.exit(1);
}
}
-88
View File
@@ -1,88 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
ListResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopListItem } from "./types.js";
interface LoopListRow {
id: string;
name: string | null;
status: string;
cwd: string;
updated: string;
activeIteration: string;
}
export interface LoopLsOptions extends CommandOptions {}
export const loopLsSchema: OutputSchema<LoopListRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
{ header: "CWD", field: "cwd", width: 40 },
{ header: "UPDATED", field: "updated", width: 24 },
],
};
export function addLoopLsOptions(command: Command): Command {
return command.description("List loops");
}
function toRow(loop: LoopListItem): LoopListRow {
return {
id: loop.id,
name: loop.name,
status: loop.status,
cwd: loop.cwd,
updated: loop.updatedAt,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopLsResult = ListResult<LoopListRow>;
export async function runLoopLsCommand(
options: LoopLsOptions,
_command: Command,
): Promise<LoopLsResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopList();
await client.close();
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.loops.map(toRow),
schema: loopLsSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_LIST_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-161
View File
@@ -1,161 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { parseDuration } from "../../utils/duration.js";
import type { LoopDaemonClient, LoopRecord, LoopRunInput } from "./types.js";
export interface LoopRunRow {
id: string;
status: string;
name: string | null;
cwd: string;
}
export interface LoopRunOptions extends CommandOptions {
provider?: "claude" | "codex" | "opencode";
model?: string;
verifyProvider?: "claude" | "codex" | "opencode";
verifyModel?: string;
verify?: string;
verifyCheck?: string[];
archive?: boolean;
name?: string;
sleep?: string;
maxIterations?: string;
maxTime?: string;
}
export const loopRunSchema: OutputSchema<LoopRunRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "CWD", field: "cwd", width: 40 },
],
};
export function addLoopRunOptions(command: Command): Command {
return command
.description("Start a loop")
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify <prompt>", "Verifier agent prompt")
.option(
"--verify-check <command>",
"Shell command that must exit 0 (repeatable)",
collectMultiple,
[],
)
.option("--archive", "Archive worker and verifier agents after each iteration")
.option("--name <name>", "Optional loop name")
.option("--sleep <duration>", "Delay between iterations (for example: 30s, 5m)")
.option("--max-iterations <n>", "Maximum number of iterations")
.option("--max-time <duration>", "Maximum total runtime (for example: 1h, 30m)");
}
function toRow(loop: LoopRecord): LoopRunRow {
return {
id: loop.id,
status: loop.status,
name: loop.name,
cwd: loop.cwd,
};
}
function parseMaxIterations(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_MAX_ITERATIONS",
message: "--max-iterations must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInput {
const verifyPrompt = options.verify?.trim();
if (options.verify !== undefined && !verifyPrompt) {
throw {
code: "INVALID_VERIFY_PROMPT",
message: "--verify cannot be empty",
} satisfies CommandError;
}
return {
prompt,
cwd: process.cwd(),
...(options.provider ? { provider: options.provider } : {}),
...(options.model?.trim() ? { model: options.model.trim() } : {}),
...(options.verifyProvider ? { verifierProvider: options.verifyProvider } : {}),
...(options.verifyModel?.trim() ? { verifierModel: options.verifyModel.trim() } : {}),
...(verifyPrompt ? { verifyPrompt } : {}),
...(options.verifyCheck && options.verifyCheck.length > 0
? { verifyChecks: options.verifyCheck }
: {}),
...(options.archive ? { archive: true } : {}),
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(options.sleep ? { sleepMs: parseDuration(options.sleep) } : {}),
...(options.maxIterations ? { maxIterations: parseMaxIterations(options.maxIterations) } : {}),
...(options.maxTime ? { maxTimeMs: parseDuration(options.maxTime) } : {}),
};
}
export type LoopRunResult = SingleResult<LoopRunRow>;
export async function runLoopRunCommand(
prompt: string,
options: LoopRunOptions,
_command: Command,
): Promise<LoopRunResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
const input = buildLoopRunInput(prompt, options);
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopRun(input);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? "Loop creation failed");
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopRunSchema,
};
} catch (error) {
await client.close().catch(() => {});
if (error && typeof error === "object" && "code" in error) {
throw error;
}
throw {
code: "LOOP_RUN_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-80
View File
@@ -1,80 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface LoopStopRow {
id: string;
status: string;
activeIteration: string;
}
export interface LoopStopOptions extends CommandOptions {}
export const loopStopSchema: OutputSchema<LoopStopRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
],
};
export function addLoopStopOptions(command: Command): Command {
return command.description("Stop a running loop").argument("<id>", "Loop ID");
}
function toRow(loop: LoopRecord): LoopStopRow {
return {
id: loop.id,
status: loop.status,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopStopResult = SingleResult<LoopStopRow>;
export async function runLoopStopCommand(
id: string,
options: LoopStopOptions,
_command: Command,
): Promise<LoopStopResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopStop(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopStopSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_STOP_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-147
View File
@@ -1,147 +0,0 @@
export type LoopStatus = "running" | "succeeded" | "failed" | "stopped";
export interface LoopLogEntry {
seq: number;
timestamp: string;
iteration: number | null;
source: "loop" | "worker" | "verifier" | "verify-check";
level: "info" | "error";
text: string;
}
export interface LoopVerifyCheckResult {
command: string;
exitCode: number;
passed: boolean;
stdout: string;
stderr: string;
startedAt: string;
completedAt: string;
}
export interface LoopVerifyPromptResult {
passed: boolean;
reason: string;
verifierAgentId: string | null;
startedAt: string;
completedAt: string;
}
export interface LoopIterationRecord {
index: number;
workerAgentId: string | null;
workerStartedAt: string;
workerCompletedAt: string | null;
verifierAgentId: string | null;
status: "running" | "succeeded" | "failed" | "stopped";
workerOutcome: "completed" | "failed" | "canceled" | null;
failureReason: string | null;
verifyChecks: LoopVerifyCheckResult[];
verifyPrompt: LoopVerifyPromptResult | null;
}
export interface LoopRecord {
id: string;
name: string | null;
prompt: string;
cwd: string;
provider: "claude" | "codex" | "opencode";
model: string | null;
workerProvider: "claude" | "codex" | "opencode" | null;
workerModel: string | null;
verifierProvider: "claude" | "codex" | "opencode" | null;
verifierModel: string | null;
verifyPrompt: string | null;
verifyChecks: string[];
archive: boolean;
sleepMs: number;
maxIterations: number | null;
maxTimeMs: number | null;
status: LoopStatus;
createdAt: string;
updatedAt: string;
startedAt: string;
completedAt: string | null;
stopRequestedAt: string | null;
iterations: LoopIterationRecord[];
logs: LoopLogEntry[];
nextLogSeq: number;
activeIteration: number | null;
activeWorkerAgentId: string | null;
activeVerifierAgentId: string | null;
}
export interface LoopListItem {
id: string;
name: string | null;
status: LoopStatus;
cwd: string;
createdAt: string;
updatedAt: string;
activeIteration: number | null;
}
export interface LoopLogsResult {
loop: LoopRecord;
entries: LoopLogEntry[];
nextCursor: number;
}
export interface LoopRunPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopListPayload {
requestId: string;
loops: LoopListItem[];
error: string | null;
}
export interface LoopInspectPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopLogsPayload {
requestId: string;
loop: LoopRecord | null;
entries: LoopLogEntry[];
nextCursor: number;
error: string | null;
}
export interface LoopStopPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopRunInput {
prompt: string;
cwd: string;
provider?: "claude" | "codex" | "opencode";
model?: string;
workerProvider?: "claude" | "codex" | "opencode";
workerModel?: string;
verifierProvider?: "claude" | "codex" | "opencode";
verifierModel?: string;
verifyPrompt?: string;
verifyChecks?: string[];
archive?: boolean;
name?: string;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
}
export interface LoopDaemonClient {
loopRun(input: LoopRunInput): Promise<LoopRunPayload>;
loopList(): Promise<LoopListPayload>;
loopInspect(id: string): Promise<LoopInspectPayload>;
loopLogs(id: string, afterSeq?: number): Promise<LoopLogsPayload>;
loopStop(id: string): Promise<LoopStopPayload>;
close(): Promise<void>;
}
@@ -1,52 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
parseScheduleCreateInput,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export interface ScheduleCreateOptions extends ScheduleCommandOptions {
every?: string;
cron?: string;
name?: string;
target?: string;
maxRuns?: string;
expiresIn?: string;
}
export async function runCreateCommand(
prompt: string,
options: ScheduleCreateOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const input = parseScheduleCreateInput({
prompt,
every: options.every,
cron: options.cron,
name: options.name,
target: options.target,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
});
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleCreate(input);
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? "Schedule creation failed");
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_CREATE_FAILED", "create schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,43 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import type { OutputSchema } from "../../output/index.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
interface ScheduleDeleteRow {
id: string;
status: string;
}
const scheduleDeleteSchema: OutputSchema<ScheduleDeleteRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 12 },
],
};
export async function runDeleteCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleDeleteRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleDelete({ id });
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "single",
data: {
id: payload.scheduleId,
status: "deleted",
},
schema: scheduleDeleteSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_DELETE_FAILED", "delete schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,68 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runLogsCommand } from "./logs.js";
import { runPauseCommand } from "./pause.js";
import { runResumeCommand } from "./resume.js";
import { runDeleteCommand } from "./delete.js";
export function createScheduleCommand(): Command {
const schedule = new Command("schedule").description("Manage recurring schedules");
addJsonAndDaemonHostOptions(
schedule
.command("create")
.description("Create a schedule")
.argument("<prompt>", "Prompt to run on the schedule")
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
.option("--cron <expr>", "Cron cadence expression")
.option("--name <name>", "Optional schedule name")
.option("--target <self|new-agent|agent-id>", "Run target")
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
schedule.command("ls").description("List schedules"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
schedule
.command("inspect")
.description("Inspect a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
schedule
.command("logs")
.description("Show recent schedule run logs")
.argument("<id>", "Schedule ID"),
).action(withOutput(runLogsCommand));
addJsonAndDaemonHostOptions(
schedule
.command("pause")
.description("Pause a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runPauseCommand));
addJsonAndDaemonHostOptions(
schedule
.command("resume")
.description("Resume a paused schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runResumeCommand));
addJsonAndDaemonHostOptions(
schedule
.command("delete")
.description("Delete a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runDeleteCommand));
return schedule;
}
@@ -1,32 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
createScheduleInspectRows,
createScheduleInspectSchema,
type ScheduleInspectRow,
} from "./schema.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
export async function runInspectCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleInspectRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleInspect({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Schedule not found: ${id}`);
}
const rows = createScheduleInspectRows(payload.schedule);
return {
type: "list",
data: rows,
schema: createScheduleInspectSchema(payload.schedule),
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_INSPECT_FAILED", "inspect schedule", error);
} finally {
await client.close().catch(() => {});
}
}

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