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
111 changed files with 13277 additions and 3194 deletions
-30
View File
@@ -1,35 +1,5 @@
# Changelog
## 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.
+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
+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
+1096 -19
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.34",
"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",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.34",
"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.34",
"@getpaseo/highlight": "0.1.34",
"@getpaseo/server": "0.1.34",
"@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",
+13 -15
View File
@@ -950,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>
@@ -1205,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,11 +31,11 @@ import {
ChevronRight,
Copy,
ExternalLink,
FolderPlus,
FolderGit2,
GitPullRequest,
Monitor,
MoreVertical,
Plus,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -392,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
}
@@ -411,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}
+4 -11
View File
@@ -525,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);
@@ -753,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);
@@ -862,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
) {
@@ -967,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 (
@@ -1009,7 +1006,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const result = processAgentStreamEvent({
event: streamEvent,
seq,
epoch,
currentTail,
currentHead,
currentCursor,
@@ -1033,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
) {
@@ -1043,7 +1037,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (
current &&
current.epoch === nextCursor.epoch &&
current.startSeq === nextCursor.startSeq &&
current.endSeq === nextCursor.endSeq
) {
@@ -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";
}
@@ -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
View File
@@ -191,7 +191,6 @@ export type DaemonServerInfo = {
};
export interface AgentTimelineCursorState {
epoch: string;
startSeq: number;
endSeq: number;
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.34",
"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.34",
"@getpaseo/server": "0.1.34",
"@getpaseo/relay": "0.1.33",
"@getpaseo/server": "0.1.33",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
-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) {
-1
View File
@@ -11,7 +11,6 @@ export async function fetchProjectedTimelineItems(
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
limit: 0,
projection: "projected",
});
return timeline.entries.map((entry) => entry.item);
}
-3
View File
@@ -14,7 +14,6 @@ publish:
owner: getpaseo
repo: paseo
mac:
artifactName: "Paseo-${version}-${arch}.${ext}"
category: public.app-category.developer-tools
icon: assets/icon.icns
hardenedRuntime: true
@@ -36,8 +35,6 @@ linux:
to: bin/paseo
target:
- AppImage
- deb
- rpm
- tar.gz
win:
icon: assets/icon.ico
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.34",
"version": "0.1.33",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.34",
"@getpaseo/server": "0.1.34",
"@getpaseo/cli": "0.1.33",
"@getpaseo/server": "0.1.33",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.34",
"version": "0.1.33",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.34",
"version": "0.1.33",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.34",
"version": "0.1.33",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+339
View File
@@ -0,0 +1,339 @@
# Voice Assistant System Prompt
## 1. Core Voice Rules (NON-NEGOTIABLE)
### Voice Context
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
### Voice Message Envelope
Some user utterances will be wrapped in an XML tag like:
`<voice-transcription focused-agent-id="...">...</voice-transcription>`
- Treat the inner text as what the user said (STT output).
- `focused-agent-id` is **context only**: it means which agent screen the user is currently looking at. It does *not* mean all actions must target that agent, but it is a strong hint for ambiguous commands like “stop it” or “cancel the agent”.
**Critical constraints:**
- User typically codes from their **phone** using voice
- **No visual feedback** - they can't see command output unless at laptop
- Input comes through **speech-to-text (STT)** which makes errors
- Output is spoken via **text-to-speech (TTS)**
- User may be mobile, away from desk, multitasking
### Communication Rules
**1-3 sentences maximum per response. Always.**
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
- **Progressive disclosure** - answer what's asked, let user ask for more
- **Start high-level** - give the gist, not every detail
- **Natural pauses** - leave room for user to respond or redirect
**Good example:**
```
User: "List my agents"
You: "You have two agents. One working on authentication in the web app, another running tests in Faro."
User: "How's the auth agent doing?"
You: "It finished adding the login flow and is waiting for your approval on the database migration."
```
**Bad example:**
```
User: "List my agents"
You: "You have 2 agents: 1. **auth-agent** - Working on authentication 2. **test-agent** - Running tests..."
```
### Handling STT Errors
Speech-to-text makes mistakes. Fix them silently using context.
**Common errors:**
- Homophones: "list" → "missed", "code" → "load"
- Project names: "faro" → "pharaoh", "mcp" → "empty"
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
**How to handle:**
1. Use context to fix obvious mistakes silently
2. Ask for clarification only when truly ambiguous
3. Never lecture about the error - just handle it
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
**Examples:**
- User: "Run empty install" → Interpret as "Run npm install"
- User: "Check the agent" → If only one agent, check that one; if multiple, ask which
### Immediate Silence Protocol
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
- "I'm not talking to you"
- "Shut up" / "Be quiet" / "Stop talking"
- "Not you"
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
## 2. Delegation Pattern
### Core Rule: Always Work Through Coding Agents
Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately.
### Safe Operations (Execute Immediately)
These orchestration tools only read or summarize state:
- `list_agents()` – discover who exists before delegating
- `get_agent_activity()` – pull the curated activity/readout for a specific agent
- `wait_for_agent()` – block until an agent requests permission or completes the current run
Call them without asking when context requires it. Never fabricate their output.
### Delegated Operations (Announce + Execute)
- `create_agent()` – Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately.
- `send_agent_prompt()` – Route the user’s request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt.
- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` – Only when the user directs you to or the agent is stuck. Confirm destructive actions.
After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user.
### When to Ask vs Act
Ask only when the routing decision is truly ambiguous. Otherwise:
- Default to the most recently addressed agent.
- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch.
- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating).
### Tool Results Reporting
After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked.
## 4. Agent Integrations
### Your Role: Orchestrator
You orchestrate work. Agents execute.
**First action when agent work is mentioned: Call `list_agents()`**
Load the agent list before any agent interaction. Always.
#### Focus Management
- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent.
- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop.
- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?”
- Always narrate hand-offs: “Okay, handing that to Planner.”
- Every time you speak on behalf of an agent, prefix with `Agent <name> says …` so the user always knows who just responded and can redirect explicitly.
**Confirm before destructive agent operations:**
- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative)
- Killing agents: "Kill agent [id] working on [task]?"
**Delegate vs execute:**
- Everything touching code, git, or shell runs through agents
- Keep lightweight questions or summaries in-orchestrator when no action is needed
- Active agent context → Send prompt to that agent (respect focus)
### Available Agents (Source of Truth)
We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations.
**Claude Code (`claude`)**
- Default mode: `plan`
- Alternate mode: `bypassPermissions`
- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution.
**Codex (`codex`)**
- Default mode: `auto`
- Other modes: `read-only`, `full-access`
- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access.
### Creating Agents
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask.
```javascript
// Claude Code with planning
create_agent({
cwd: "~/dev/paseo",
agentType: "claude",
initialPrompt: "add dark mode toggle to settings page",
initialMode: "plan"
})
// Codex for quick edits
create_agent({
cwd: "~/dev/paseo",
agentType: "codex",
initialPrompt: "clean up the logging",
initialMode: "auto"
})
```
If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error.
### Working with Agents
**Send prompts to agents:**
```javascript
// Send task (non-blocking by default)
send_agent_prompt({
agentId: "abc123",
prompt: "explain how authentication works"
})
// Returns immediately, agent processes in background
// Send task and wait for completion
send_agent_prompt({
agentId: "abc123",
prompt: "fix the bug in auth.ts",
maxWait: 60000 // Wait up to 60 seconds
})
// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access)
send_agent_prompt({
agentId: "abc123",
prompt: "implement user registration",
sessionMode: "bypassPermissions"
})
```
**Check agent status:**
```javascript
// Get current status
get_agent_status({ agentId: "abc123" })
// Returns: { status: "processing", info: {...} }
// Get agent activity (curated, human-readable)
get_agent_activity({
agentId: "abc123",
format: "curated" // Clean summary of what agent did
})
// List all agents
list_agents()
// Returns: { agents: [{id, status, createdAt, ...}, ...] }
```
**Control agents:**
```javascript
// Change session mode (safe, no confirmation needed)
set_agent_mode({
agentId: "abc123",
modeId: "plan"
})
// Cancel current task (safe, no confirmation needed)
cancel_agent({ agentId: "abc123" })
// Kill agent (REQUIRES confirmation first)
kill_agent({ agentId: "abc123" })
```
### Agent Workflow Pattern
```javascript
// 1. Load agents first
list_agents()
// 2. If creating new agent, confirm first
// You: "Create agent in ~/dev/project for authentication?"
// User: "yes"
// 3. Create with type + mode
create_agent({
cwd: "~/dev/project",
agentType: "claude",
initialPrompt: "add authentication",
initialMode: "plan"
})
// 4. Monitor or send follow-up tasks
get_agent_activity({ agentId })
send_agent_prompt({ agentId, prompt: "add tests" })
```
## 5. Git & GitHub
### Git Worktree Utilities
Custom utilities for safe worktree management:
**create-worktree:**
- Creates new git worktree with new branch
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
- Outputs WORKTREE_PATH for you to parse
**delete-worktree:**
- Preserves the branch, only deletes directory
- Safe to use - won't lose work
- Run from within worktree directory
### GitHub CLI (gh)
Already authenticated. Use for:
- Creating PRs: `gh pr create`
- Viewing PRs: `gh pr view`
- Managing issues: `gh issue list`
- Checking CI: `gh pr checks`
## 6. Projects & Context
### Project Locations
All projects in `~/dev`:
**paseo**
- Location: `~/dev/paseo`
- Packages: `voice-assistant`
**Faro** (Autonomous Competitive Intelligence)
- Bare repo: `~/dev/faro`
- Main checkout: `~/dev/faro/main`
**Blank.page** (Minimal browser text editor)
- Location: `~/dev/blank.page/editor`
### Decision Rules
**Agent work mentioned?**
1. Call `list_agents()` first
2. Reuse existing agent if task relates to its work
3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately.
**Creating/killing agents?**
- Ask: "Create agent in [dir] for [task]?" when intent isn’t explicit
- Ask: "Kill agent [id]?"
- Wait for "yes"
**Task routing:**
- All coding tasks → Delegate to an agent
- Active agent + related work → Delegate to that agent
- If the user explicitly mentions another agent, switch focus before delegating
**Context tracking:**
- Track active agents and their directories
- Use conversation context to resolve ambiguity
- Fix STT errors silently
- Maintain a recency-based focus pointer and narrate any focus change out loud
### Core Reminders
- Call actual tools, never just describe
- 1-3 sentences max per response
- Always report agent/tool results verbally (preface with "Agent X says …" when relaying)
- Default to action when context is clear
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./packages/server/src/server/db/schema.ts",
out: "./packages/server/src/server/db/migrations",
dialect: "postgresql",
strict: true,
verbose: true,
});
+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.34",
"version": "0.1.33",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -13,7 +13,8 @@
"dist/scripts",
"src/server/speech/providers/local/sherpa/assets",
"README.md",
".env.example"
".env.example",
"agent-prompt.md"
],
"exports": {
".": {
@@ -45,6 +46,7 @@
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"db:query": "tsx scripts/db-query.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
@@ -63,8 +65,9 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.34",
"@getpaseo/relay": "0.1.34",
"@electric-sql/pglite": "^0.4.1",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/relay": "0.1.33",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -72,6 +75,7 @@
"ai": "5.0.78",
"ajv": "^8.17.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"fast-uri": "^3.1.0",
@@ -95,6 +99,7 @@
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"drizzle-kit": "^0.31.10",
"@types/express": "^4.17.20",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env npx tsx
/**
* Run arbitrary SQL against the running Paseo daemon's PGlite database.
*
* Connects to the daemon's dev query endpoint (available in dev mode only).
*
* Usage:
* npx tsx packages/server/scripts/db-query.ts "SELECT * FROM agent_snapshots"
* npx tsx packages/server/scripts/db-query.ts --port 6767 "SELECT count(*) FROM agent_timeline_rows"
*
* Without args, shows table row counts.
*/
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
function findDaemonPort(explicit?: string): number {
if (explicit) return parseInt(explicit, 10);
// Check for running dev daemon config
const tmpDir = os.tmpdir();
for (const entry of fs.readdirSync(tmpDir)) {
if (entry.startsWith("paseo-dev.")) {
const configPath = path.join(tmpDir, entry, "config.json");
if (fs.existsSync(configPath)) {
try {
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const listen = config.daemon?.listen;
if (typeof listen === "string") {
const portMatch = listen.match(/:(\d+)$/);
if (portMatch) return parseInt(portMatch[1]!, 10);
}
} catch {}
}
}
}
return 6767;
}
async function main() {
const args = process.argv.slice(2);
let port: string | undefined;
const queries: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--port" && args[i + 1]) {
port = args[++i];
} else {
queries.push(args[i]!);
}
}
if (queries.length === 0) {
queries.push(
"SELECT 'agent_snapshots' AS table_name, count(*)::int AS rows FROM agent_snapshots UNION ALL " +
"SELECT 'agent_timeline_rows', count(*)::int FROM agent_timeline_rows UNION ALL " +
"SELECT 'projects', count(*)::int FROM projects UNION ALL " +
"SELECT 'workspaces', count(*)::int FROM workspaces " +
"ORDER BY table_name",
);
}
const daemonPort = findDaemonPort(port);
const url = `http://localhost:${daemonPort}/dev/db-query`;
for (const sql of queries) {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql }),
});
if (!res.ok) {
const text = await res.text();
console.error(`HTTP ${res.status}: ${text}`);
continue;
}
const { rows } = await res.json();
if (rows.length === 0) {
console.log("(0 rows)\n");
} else {
console.table(rows);
}
} catch (err: any) {
if (err.cause?.code === "ECONNREFUSED") {
console.error(`Cannot connect to daemon at port ${daemonPort}. Is it running?`);
process.exit(1);
}
console.error(`Error: ${err.message}\n`);
}
}
}
main();
@@ -1704,24 +1704,15 @@ describe("DaemonClient", () => {
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
startSeq: 1,
endSeq: 1,
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
seq: 1,
item: {
type: "tool_call",
callId: "call_cli_snapshot",
@@ -1798,24 +1789,15 @@ describe("DaemonClient", () => {
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
startSeq: 1,
endSeq: 1,
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
seq: 1,
item: {
type: "tool_call",
callId: "call_cli_invalid",
+3 -6
View File
@@ -130,7 +130,6 @@ export type DaemonEvent =
event: AgentStreamEventPayload;
timestamp: string;
seq?: number;
epoch?: string;
}
| { type: "status"; payload: { status: string } & Record<string, unknown> }
| { type: "agent_deleted"; agentId: string }
@@ -244,13 +243,13 @@ type KillTerminalPayload = KillTerminalResponse["payload"];
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
export type FetchAgentTimelineCursor = NonNullable<
Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>["cursor"]
>;
export type FetchAgentTimelineOptions = {
direction?: FetchAgentTimelineDirection;
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
requestId?: string;
};
@@ -1396,7 +1395,6 @@ export class DaemonClient {
...(options.direction ? { direction: options.direction } : {}),
...(options.cursor ? { cursor: options.cursor } : {}),
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
...(options.projection ? { projection: options.projection } : {}),
});
const payload = await this.sendRequest({
@@ -3100,7 +3098,6 @@ export class DaemonClient {
event: msg.payload.event,
timestamp: msg.payload.timestamp,
...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}),
...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}),
};
case "status":
return { type: "status", payload: msg.payload };
@@ -0,0 +1,128 @@
import type pino from "pino";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
export type AgentLoadingServiceOptions = {
agentManager: Pick<
AgentManager,
| "createAgent"
| "getAgent"
| "reloadAgentSession"
| "resumeAgentFromPersistence"
>;
agentStorage: Pick<AgentSnapshotStore, "get">;
logger: pino.Logger;
};
// Coordinates cold loads, explicit resumes, and refreshes for persisted agents.
export class AgentLoadingService {
private readonly agentManager: AgentLoadingServiceOptions["agentManager"];
private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"];
private readonly logger: pino.Logger;
constructor(options: AgentLoadingServiceOptions) {
this.agentManager = options.agentManager;
this.agentStorage = options.agentStorage;
this.logger = options.logger.child({ component: "agent-loading" });
}
async ensureAgentLoaded(options: { agentId: string }): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(options.agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentBootstrapLoads.get(options.agentId);
if (inflight) {
return inflight;
}
const initPromise = this.loadStoredAgent(options);
pendingAgentBootstrapLoads.set(options.agentId, initPromise);
try {
return await initPromise;
} finally {
const current = pendingAgentBootstrapLoads.get(options.agentId);
if (current === initPromise) {
pendingAgentBootstrapLoads.delete(options.agentId);
}
}
}
async resumeAgent(options: {
handle: AgentPersistenceHandle;
overrides?: Partial<AgentSessionConfig>;
}): Promise<ManagedAgent> {
return this.agentManager.resumeAgentFromPersistence(options.handle, options.overrides);
}
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(options.agentId);
if (existing) {
return existing.persistence
? await this.agentManager.reloadAgentSession(options.agentId)
: existing;
}
const record = await this.agentStorage.get(options.agentId);
if (!record) {
throw new Error(`Agent not found: ${options.agentId}`);
}
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
if (!handle) {
throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`);
}
return this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
options.agentId,
extractTimestamps(record),
);
}
private async loadStoredAgent(options: { agentId: string }): Promise<ManagedAgent> {
const record = await this.agentStorage.get(options.agentId);
if (!record) {
throw new Error(`Agent not found: ${options.agentId}`);
}
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
options.agentId,
extractTimestamps(record),
);
this.logger.info(
{ agentId: options.agentId, provider: record.provider },
"Agent resumed from persistence",
);
} else {
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, {
labels: record.labels,
});
this.logger.info(
{ agentId: options.agentId, provider: record.provider },
"Agent created from stored config",
);
}
return this.agentManager.getAgent(options.agentId) ?? snapshot;
}
}
@@ -37,7 +37,7 @@ import {
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -51,7 +51,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
paseoHome?: string;
logger: Logger;
@@ -178,7 +178,7 @@ function sanitizePermissionRequest(
}
async function resolveAgentTitle(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
agentId: string,
logger: Logger,
): Promise<string | null> {
@@ -192,7 +192,7 @@ async function resolveAgentTitle(
}
async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
snapshot: ManagedAgent,
logger: Logger,
) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
import type { ManagedAgent } from "./agent-manager.js";
import type { StoredAgentRecord } from "./agent-storage.js";
export interface AgentSnapshotStore {
list(): Promise<StoredAgentRecord[]>;
get(agentId: string): Promise<StoredAgentRecord | null>;
upsert(record: StoredAgentRecord): Promise<void>;
remove(agentId: string): Promise<void>;
applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
setTitle(agentId: string, title: string): Promise<void>;
}
@@ -7,6 +7,7 @@ import type { Logger } from "pino";
import { AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
const SERIALIZABLE_CONFIG_SCHEMA = z
@@ -69,8 +70,11 @@ export type SerializableAgentConfig = Pick<
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
return STORED_AGENT_SCHEMA.parse(value);
}
export class AgentStorage {
export class AgentStorage implements AgentSnapshotStore {
private cache: Map<string, StoredAgentRecord> = new Map();
private pathById: Map<string, string> = new Map();
private pathsById: Map<string, Set<string>> = new Map();
@@ -300,7 +304,7 @@ export class AgentStorage {
try {
const content = await fs.readFile(filePath, "utf8");
const parsed = JSON.parse(content);
return STORED_AGENT_SCHEMA.parse(parsed);
return parseStoredAgentRecord(parsed);
} catch (error) {
this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
return null;
@@ -0,0 +1,60 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export type AgentTimelineRow = {
seq: number;
timestamp: string;
item: AgentTimelineItem;
};
export type AgentTimelineCursor = {
seq: number;
};
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
export type AgentTimelineFetchOptions = {
direction?: AgentTimelineFetchDirection;
cursor?: AgentTimelineCursor;
/**
* Number of canonical rows to return.
* - undefined: store default
* - 0: all rows in the selected window
*/
limit?: number;
};
export type AgentTimelineWindow = {
minSeq: number;
maxSeq: number;
nextSeq: number;
};
export type AgentTimelineFetchResult = {
direction: AgentTimelineFetchDirection;
window: AgentTimelineWindow;
hasOlder: boolean;
hasNewer: boolean;
rows: AgentTimelineRow[];
};
export interface AgentTimelineStore {
appendCommitted(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): Promise<AgentTimelineRow>;
fetchCommitted(
agentId: string,
options?: AgentTimelineFetchOptions,
): Promise<AgentTimelineFetchResult>;
getLatestCommittedSeq(agentId: string): Promise<number>;
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
getLastAssistantMessage(agentId: string): Promise<string | null>;
hasCommittedUserMessage(
agentId: string,
options: { messageId: string; text: string },
): Promise<boolean>;
deleteAgent(agentId: string): Promise<void>;
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
}
@@ -0,0 +1,244 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
} from "./agent-timeline-store-types.js";
export type SeedAgentTimelineOptions = {
items?: readonly AgentTimelineItem[];
rows?: readonly AgentTimelineRow[];
nextSeq?: number;
timestamp?: string;
};
type AgentTimelineState = {
rows: AgentTimelineRow[];
nextSeq: number;
};
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
return { ...row };
}
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
if (typeof messageId !== "string") {
return undefined;
}
const normalized = messageId.trim();
return normalized.length > 0 ? normalized : undefined;
}
export class InMemoryAgentTimelineStore {
private readonly states = new Map<string, AgentTimelineState>();
has(agentId: string): boolean {
return this.states.has(agentId);
}
initialize(agentId: string, options?: SeedAgentTimelineOptions): void {
const timestamp = options?.timestamp ?? new Date().toISOString();
const rows = options?.rows?.length
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq =
options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
this.states.set(agentId, {
rows,
nextSeq,
});
}
delete(agentId: string): void {
this.states.delete(agentId);
}
getItems(agentId: string): AgentTimelineItem[] {
return this.requireState(agentId).rows.map((row) => row.item);
}
getRows(agentId: string): AgentTimelineRow[] {
return this.requireState(agentId).rows.map(cloneRow);
}
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const requestedLimit = options?.limit;
const limit =
requestedLimit === undefined
? DEFAULT_TIMELINE_FETCH_LIMIT
: Math.max(0, Math.floor(requestedLimit));
const cursor = options?.cursor;
const minSeq = state.rows.length ? state.rows[0]!.seq : 0;
const maxSeq = state.rows.length ? state.rows[state.rows.length - 1]!.seq : 0;
const selectAll = limit === 0;
const window = {
minSeq,
maxSeq,
nextSeq: state.nextSeq,
};
if (state.rows.length === 0) {
return {
direction,
window,
hasOlder: false,
hasNewer: false,
rows: [],
};
}
if (direction === "tail") {
const selected =
selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit);
return {
direction,
window,
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
hasNewer: false,
rows: selected.map(cloneRow),
};
}
if (direction === "after") {
const baseSeq = cursor?.seq ?? 0;
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
if (startIdx < 0) {
return {
direction,
window,
hasOlder: baseSeq >= minSeq,
hasNewer: false,
rows: [],
};
}
const selected = selectAll
? state.rows.slice(startIdx)
: state.rows.slice(startIdx, startIdx + limit);
const lastSelected = selected[selected.length - 1];
return {
direction,
window,
hasOlder: selected[0]!.seq > minSeq,
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
rows: selected.map(cloneRow),
};
}
const beforeSeq = cursor?.seq ?? state.nextSeq;
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
const selected =
selectAll || limit >= boundedRows.length
? boundedRows
: boundedRows.slice(boundedRows.length - limit);
return {
direction,
window,
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
hasNewer: endExclusive >= 0,
rows: selected.map(cloneRow),
};
}
append(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): AgentTimelineRow {
const state = this.requireState(agentId);
const row: AgentTimelineRow = {
seq: state.nextSeq,
timestamp: options?.timestamp ?? new Date().toISOString(),
item,
};
state.nextSeq += 1;
state.rows.push(row);
return cloneRow(row);
}
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.rows[state.rows.length - 1]?.item ?? null;
}
getLastAssistantMessage(agentId: string): string | null {
const rows = this.requireState(agentId).rows;
const chunks: string[] = [];
for (let i = rows.length - 1; i >= 0; i -= 1) {
const item = rows[i]!.item;
if (item.type !== "assistant_message") {
if (chunks.length > 0) {
break;
}
continue;
}
chunks.push(item.text);
}
if (chunks.length === 0) {
return null;
}
return chunks.reverse().join("");
}
getCanonicalUserMessagesById(agentId: string): Map<string, string> {
const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => {
if (row.item.type !== "user_message") {
return [];
}
const messageId = normalizeTimelineMessageId(row.item.messageId);
if (!messageId) {
return [];
}
return [[messageId, row.item.text]];
});
return new Map(entries);
}
hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean {
const messageId = normalizeTimelineMessageId(options.messageId);
if (!messageId) {
return false;
}
return this.requireState(agentId).rows.some((row) => {
if (row.item.type !== "user_message") {
return false;
}
const rowMessageId = normalizeTimelineMessageId(row.item.messageId);
return rowMessageId === messageId && row.item.text === options.text;
});
}
private requireState(agentId: string): AgentTimelineState {
const state = this.states.get(agentId);
if (!state) {
throw new Error(`Unknown agent '${agentId}'`);
}
return state;
}
private buildRowsFromItems(
items: readonly AgentTimelineItem[],
startSeq: number,
timestamp: string,
): AgentTimelineRow[] {
let nextSeq = startSeq;
return items.map((item) => {
const row: AgentTimelineRow = {
seq: nextSeq,
timestamp,
item,
};
nextSeq += 1;
return row;
});
}
}
@@ -6,11 +6,11 @@ import { tmpdir } from "node:os";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
type TestDeps = {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
spies: {
agentManager: Record<string, any>;
agentStorage: Record<string, any>;
@@ -41,7 +41,7 @@ function createTestDeps(): TestDeps {
return {
agentManager: agentManagerSpies as unknown as AgentManager,
agentStorage: agentStorageSpies as unknown as AgentStorage,
agentStorage: agentStorageSpies as unknown as AgentSnapshotStore,
spies: {
agentManager: agentManagerSpies,
agentStorage: agentStorageSpies,
@@ -16,7 +16,7 @@ import {
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -31,7 +31,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
paseoHome?: string;
/**
@@ -240,7 +240,7 @@ function sanitizePermissionRequest(
}
async function resolveAgentTitle(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
agentId: string,
logger: Logger,
): Promise<string | null> {
@@ -254,7 +254,7 @@ async function resolveAgentTitle(
}
async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
snapshot: ManagedAgent,
logger: Logger,
) {
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import type { AgentModelDefinition } from "../agent-sdk-types.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
@@ -16,16 +15,6 @@ function isBinaryInstalled(binary: string): boolean {
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
function modelMatchesFamily(
model: AgentModelDefinition,
family: "sonnet" | "haiku",
): boolean {
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
value.toLowerCase(),
);
return haystacks.some((text) => text.includes(family));
}
describe("provider model catalogs (e2e)", () => {
let ctx: DaemonTestContext;
@@ -43,8 +32,11 @@ describe("provider model catalogs (e2e)", () => {
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
const descriptions = result.models.map((model) =>
`${model.label} ${model.description ?? ""}`.toLowerCase(),
);
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
}, 180_000);
test.runIf(hasCodex)(
@@ -336,7 +336,11 @@ describe("Agent event stream redesign — integration", () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
let turnId: string | null = null;
const { turnId } = await handle.session.startTurn(
"write a very long essay about the history of computing",
);
// Use a single subscription to avoid missing events between unsubscribe/resubscribe
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
@@ -348,14 +352,10 @@ describe("Agent event stream redesign — integration", () => {
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
@@ -365,37 +365,14 @@ describe("Agent event stream redesign — integration", () => {
}
// Resolve when we get a terminal event for this turn
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
if (isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
}
});
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
expect(turnId).toBeDefined();
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
@@ -441,7 +441,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
test("routes input_json_delta through partial parsing before buffered JSON is complete", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
@@ -479,7 +479,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
partial_json: '{"command":"echo ',
},
},
expectedCommand: "echo seed",
expectedCommand: "echo ",
},
{
event: {
@@ -520,7 +520,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("does not surface incomplete string values from input_json_delta", async () => {
test("surfaces canonical partial tool input from input_json_delta before JSON is complete", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
@@ -551,6 +551,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({
file_path: "src/message.tsx",
old_string: "before",
});
} finally {
await session.close();
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
@@ -357,4 +357,52 @@ describe("ClaudeAgentClient.listModels", () => {
]);
expect(queryMock.return).toHaveBeenCalledTimes(1);
});
test("keeps the Claude control-plane query open until supportedModels resolves", async () => {
const queryMock = createSupportedModelsQueryMock([
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
},
] satisfies ModelInfo[]);
let promptIterator: AsyncIterator<SDKUserMessage, void> | null = null;
let promptNextPromise: Promise<IteratorResult<SDKUserMessage, void>> | null = null;
let promptClosedBeforeModelsResolved = false;
queryMock.supportedModels = vi.fn(async () => {
promptNextPromise = promptIterator?.next() ?? null;
if (!promptNextPromise) {
throw new Error("Prompt iterator not captured");
}
promptNextPromise.then(() => {
promptClosedBeforeModelsResolved = true;
});
await Promise.resolve();
expect(promptClosedBeforeModelsResolved).toBe(false);
return [
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
},
] satisfies ModelInfo[];
});
const queryFactory = vi.fn(({ prompt }) => {
promptIterator = prompt[Symbol.asyncIterator]();
return queryMock;
});
const client = new ClaudeAgentClient({
logger,
queryFactory: queryFactory as never,
});
const models = await client.listModels({ cwd: process.cwd() });
expect(models).toHaveLength(1);
expect(promptNextPromise).not.toBeNull();
await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined });
expect(queryMock.return).toHaveBeenCalledTimes(1);
});
});
@@ -225,10 +225,6 @@ function applyRuntimeSettingsToClaudeOptions(
};
}
function createEmptyClaudePrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
return (async function* empty() {})();
}
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
return value === "low" || value === "medium" || value === "high" || value === "max";
}
@@ -1032,8 +1028,9 @@ export class ClaudeAgentClient implements AgentClient {
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const input = createAsyncMessageInput<SDKUserMessage>();
const claudeQuery = this.queryFactory({
prompt: createEmptyClaudePrompt(),
prompt: input.iterable,
options: applyRuntimeSettingsToClaudeOptions(
{
cwd: options?.cwd ?? process.cwd(),
@@ -1052,13 +1049,13 @@ export class ClaudeAgentClient implements AgentClient {
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
throw error;
} finally {
input.end();
try {
await claudeQuery.return?.();
} catch {
// ignore control-plane shutdown errors
}
}
}
async listPersistedAgents(
@@ -13,25 +13,33 @@ describe("parsePartialJsonObject", () => {
});
});
it("does not emit incomplete string values", () => {
it("keeps partial string values without field-specific logic", () => {
expect(parsePartialJsonObject('{"command":"echo ')).toEqual({
value: {},
complete: false,
});
});
it("returns only complete prefix fields from incomplete objects", () => {
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
value: {
file_path: "src/message.tsx",
command: "echo ",
},
complete: false,
});
});
it("does not emit incomplete nested values", () => {
it("returns parsed prefix fields from incomplete objects", () => {
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
value: {
file_path: "src/message.tsx",
old_string: "before",
},
complete: false,
});
});
it("parses nested partial values generically", () => {
expect(parsePartialJsonObject('{"payload":{"path":"src/index.ts","content":"hello')).toEqual({
value: {},
value: {
payload: {
path: "src/index.ts",
content: "hello",
},
},
complete: false,
});
});
@@ -271,17 +271,17 @@ function parsePartialObject(
};
}
value[parsedKey.value] = parsedMemberValue.value;
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
if (!parsedMemberValue.complete) {
return {
value,
nextIndex: skipWhitespace(input, parsedMemberValue.nextIndex),
nextIndex: currentIndex,
complete: false,
};
}
value[parsedKey.value] = parsedMemberValue.value;
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
const delimiter = input[currentIndex];
if (delimiter === ",") {
currentIndex += 1;
@@ -0,0 +1,22 @@
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load system prompt from agent-prompt.md in package root
export function loadSystemPrompt(): string {
const promptPath = path.join(__dirname, "../../../agent-prompt.md");
return readFileSync(promptPath, "utf-8");
}
// Cache the loaded prompt
let cachedPrompt: string | null = null;
export function getSystemPrompt(): string {
if (!cachedPrompt) {
cachedPrompt = loadSystemPrompt();
}
return cachedPrompt;
}
@@ -1,5 +1,6 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
@@ -8,6 +9,8 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { openPaseoDatabase } from "./db/pglite-database.js";
import { agentSnapshots, projects, workspaces } from "./db/schema.js";
describe("paseo daemon bootstrap", () => {
afterEach(() => {
@@ -182,4 +185,224 @@ describe("paseo daemon bootstrap", () => {
await rm(staticDir, { recursive: true, force: true });
}
});
test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await daemon.start();
await daemon.stop();
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
expect(await database.db.select().from(projects)).toEqual([
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
]);
expect(await database.db.select().from(workspaces)).toEqual([
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
]);
} finally {
await database.close();
}
} finally {
await cleanup();
}
});
test("does not duplicate imported legacy JSON across daemon restarts", async () => {
const { config, cleanup } = await createBootstrapConfig();
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
try {
const firstDaemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await firstDaemon.start();
await firstDaemon.stop();
const secondDaemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await secondDaemon.start();
await secondDaemon.stop();
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
expect(await database.db.select().from(projects)).toHaveLength(1);
expect(await database.db.select().from(workspaces)).toHaveLength(1);
} finally {
await database.close();
}
} finally {
await cleanup();
}
});
test("reconciles workspace records into the DB without recreating legacy JSON registry files", async () => {
const { config, cleanup } = await createBootstrapConfig();
const agentStorageDir = path.join(config.paseoHome, "agents");
mkdirSync(agentStorageDir, { recursive: true });
const storageBucket = path.join(agentStorageDir, "tmp-db-only-project");
mkdirSync(storageBucket, { recursive: true });
writeFileSync(
path.join(storageBucket, "agent-1.json"),
JSON.stringify(
{
id: "agent-1",
provider: "codex",
cwd: "/tmp/db-only-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
},
null,
2,
),
"utf8",
);
try {
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await daemon.start();
await daemon.stop();
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/db-only-project",
requiresAttention: false,
internal: false,
}),
]);
expect(await database.db.select().from(projects)).toHaveLength(1);
expect(await database.db.select().from(workspaces)).toHaveLength(1);
} finally {
await database.close();
}
expect(existsSync(path.join(config.paseoHome, "projects", "projects.json"))).toBe(false);
expect(existsSync(path.join(config.paseoHome, "projects", "workspaces.json"))).toBe(false);
} finally {
await cleanup();
}
});
});
async function createBootstrapConfig(): Promise<{
config: PaseoDaemonConfig;
cleanup: () => Promise<void>;
}> {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-db-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
return {
config: {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
},
cleanup: async () => {
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
},
};
}
function writeLegacyProjectWorkspaceJson(
paseoHome: string,
input: {
projects: unknown[];
workspaces: unknown[];
},
): void {
const projectsDir = path.join(paseoHome, "projects");
mkdirSync(projectsDir, { recursive: true });
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify(input.projects, null, 2), "utf8");
writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify(input.workspaces, null, 2), "utf8");
}
+47 -22
View File
@@ -84,12 +84,17 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { initializeSpeechRuntime } from "./speech/speech-runtime.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { DbProjectRegistry } from "./db/db-project-registry.js";
import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js";
import { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js";
import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/pglite-database.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -179,7 +184,7 @@ export type PaseoDaemonConfig = {
export interface PaseoDaemon {
config: PaseoDaemonConfig;
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager: TerminalManager;
start(): Promise<void>;
stop(): Promise<void>;
@@ -197,6 +202,7 @@ export async function createPaseoDaemon(
const pidLockMode = config.pidLock?.mode ?? "self";
const pidLockOwnerPid = config.pidLock?.ownerPid;
const ownsPidLock = pidLockMode === "self";
let database: PaseoDatabaseHandle | null = null;
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
if (ownsPidLock) {
@@ -356,15 +362,27 @@ export async function createPaseoDaemon(
const httpServer = createHTTPServer(app);
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
path.join(config.paseoHome, "projects", "projects.json"),
logger,
);
const workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(config.paseoHome, "projects", "workspaces.json"),
logger,
);
database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
logger.info({ elapsed: elapsed() }, "Paseo database opened");
if (process.env.NODE_ENV === "development") {
app.post("/dev/db-query", async (req, res) => {
try {
const { sql } = req.body;
if (typeof sql !== "string") {
res.status(400).json({ error: "Missing sql string" });
return;
}
const result = await database!.client.query(sql);
res.json({ rows: result.rows });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
}
const agentStorage = new DbAgentSnapshotStore(database.db);
const durableTimelineStore = new DbAgentTimelineStore(database.db);
const agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
@@ -373,18 +391,25 @@ export async function createPaseoDaemon(
...config.agentClients,
},
registry: agentStorage,
durableTimelineStore,
logger,
});
const terminalManager = createTerminalManager();
const detachAgentStoragePersistence = attachAgentStoragePersistence(
const projectRegistry = new DbProjectRegistry(database.db);
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome: config.paseoHome,
logger,
agentManager,
agentStorage,
);
await agentStorage.initialize();
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
});
logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked");
await importLegacyAgentSnapshots({
db: database.db,
paseoHome: config.paseoHome,
logger,
});
logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked");
await bootstrapWorkspaceRegistries({
paseoHome: config.paseoHome,
agentStorage,
@@ -709,8 +734,6 @@ export async function createPaseoDaemon(
const stop = async () => {
await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await shutdownProviders(logger, {
runtimeSettings: config.agentProviderSettings,
});
@@ -725,6 +748,7 @@ export async function createPaseoDaemon(
if (voiceMcpBridgeManager) {
await voiceMcpBridgeManager.stop().catch(() => undefined);
}
await database?.close().catch(() => undefined);
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
@@ -750,6 +774,7 @@ export async function createPaseoDaemon(
getListenTarget: () => boundListenTarget,
};
} catch (err) {
await database?.close().catch(() => undefined);
if (ownsPidLock) {
await releasePidLock(config.paseoHome, {
ownerPid: pidLockOwnerPid,
@@ -224,6 +224,8 @@ describe("daemon client E2E", () => {
expect(archivedResult).not.toBeNull();
expect(archivedResult?.agent.archivedAt).toBeTruthy();
expect(archivedResult?.agent.status).not.toBe("running");
expect(archivedResult?.agent.requiresAttention).toBe(false);
expect(archivedResult?.agent.attentionReason).toBeNull();
expect(archivedResult?.project).not.toBeNull();
expect(archivedResult?.project?.checkout.cwd).toBe(cwd);
@@ -309,6 +311,42 @@ describe("daemon client E2E", () => {
}
}, 180000);
test("update_agent persists unloaded title and labels across auto-unarchive", async () => {
const cwd = tmpCwd();
try {
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd,
},
});
await ctx.client.archiveAgent(created.id);
await ctx.client.updateAgent(created.id, {
name: "Pinned Title",
labels: { lane: "phase-1a" },
});
const archived = await ctx.client.fetchAgent(created.id);
expect(archived).not.toBeNull();
expect(archived?.agent.archivedAt).toBeTruthy();
expect(archived?.agent.title).toBe("Pinned Title");
expect(archived?.agent.labels).toMatchObject({ lane: "phase-1a" });
await ctx.client.sendMessage(created.id, "Say hello and nothing else");
const finalState = await ctx.client.waitForFinish(created.id, 120000);
expect(finalState.status).toBe("idle");
const unarchived = await ctx.client.fetchAgent(created.id);
expect(unarchived).not.toBeNull();
expect(unarchived?.agent.archivedAt).toBeNull();
expect(unarchived?.agent.title).toBe("Pinned Title");
expect(unarchived?.agent.labels).toMatchObject({ lane: "phase-1a" });
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 180000);
test("returns home-scoped directory suggestions", async () => {
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
@@ -529,7 +567,6 @@ describe("daemon client E2E", () => {
const timelineResult = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "projected",
});
expect(timelineResult.agentId).toBe(agent.id);
@@ -756,7 +793,6 @@ describe("daemon client E2E", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
expect(timeline.entries.length).toBeGreaterThan(0);
@@ -71,7 +71,6 @@ describe("daemon E2E", () => {
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
@@ -89,7 +88,6 @@ describe("daemon E2E", () => {
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const clearResult = await ctx.client.fetchAgent(agent.id);
@@ -60,7 +60,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const idleAssistantText = timelineAtIdle.entries
.filter(
@@ -91,7 +90,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
const finalTimeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const finalAssistantText = finalTimeline.entries
.filter(
@@ -390,7 +390,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
await client.waitForAgentUpsert(
@@ -405,7 +404,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtIdle.entries.length,
@@ -417,7 +415,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const nextTimeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length;
}
@@ -605,7 +602,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry);
// Required by reproduction request: log timeline at idle edge before autonomous wake.
@@ -735,7 +731,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
// eslint-disable-next-line no-console
@@ -751,7 +746,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtWake.entries.length,
@@ -916,7 +910,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
// eslint-disable-next-line no-console
@@ -932,7 +925,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtWake.entries.length,
@@ -1066,7 +1058,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
assistantTexts = timeline.entries
.filter(
@@ -114,11 +114,14 @@ describe("daemon E2E - persistence", () => {
const timeline = await ctx.client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const timelineItems = timeline.entries.map((entry) => entry.item);
expect(timelineItems.length).toBeGreaterThan(0);
expect(timelineItems.some((item) => item.type === "assistant_message")).toBe(true);
const assistantMessages = timelineItems.filter(
(item): item is Extract<(typeof timelineItems)[number], { type: "assistant_message" }> =>
item.type === "assistant_message",
);
expect(assistantMessages).toEqual([{ type: "assistant_message", text: "timeline test" }]);
} finally {
await ctx.cleanup();
cleaned = true;
@@ -48,7 +48,6 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => {
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const rewindUserMessages = timeline.entries.filter(
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDaemonTestContext, type DaemonTestContext, DaemonClient } from "../test-utils/index.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import type { SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
async function waitFor(
predicate: () => boolean,
timeoutMs = 5_000,
intervalMs = 10,
): Promise<void> {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
function isSeqLessAssistantTimeline(
message: SessionOutboundMessage,
agentId: string,
text?: string,
): boolean {
return (
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "assistant_message" &&
message.payload.seq === undefined &&
(text === undefined || message.payload.event.item.text === text)
);
}
describe("daemon E2E - timeline reconnect contract", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60_000);
test("reconnect catches up committed rows without replaying a provisional seed", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect Contract Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
),
);
await ctx.client.close();
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "finalized while disconnected",
});
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-a" },
});
expect(
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { seq: 120 },
limit: 0,
});
expect(catchUp.entries).toHaveLength(1);
expect(catchUp.entries[0]?.seq).toBe(121);
expect(catchUp.entries[0]?.item).toEqual({
type: "assistant_message",
text: "finalized while disconnected",
});
} finally {
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("reconnect with no new committed rows resumes from future live provisional updates only", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect No Seed Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
),
);
await ctx.client.close();
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-b" },
});
expect(
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { seq: 120 },
limit: 0,
});
expect(catchUp.entries).toHaveLength(0);
reconnectCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "fresh live after reconnect",
});
await waitFor(() =>
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "fresh live after reconnect"),
),
);
} finally {
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
});
@@ -20,7 +20,7 @@ describe("daemon E2E - timeline window", () => {
await ctx.cleanup();
}, 60_000);
test("canonical tail limit keeps assistant chunks intact at the window boundary", async () => {
test("canonical tail limit returns one finalized committed assistant row at the window boundary", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
@@ -38,16 +38,14 @@ describe("daemon E2E - timeline window", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "canonical",
});
const assistantTexts = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text);
expect(assistantTexts).toHaveLength(2);
expect(assistantTexts.join("")).toBe(expected);
expect(timeline.startCursor?.seq).toBeLessThan(timeline.endCursor?.seq ?? 0);
expect(assistantTexts).toEqual([expected]);
expect(timeline.startSeq).toBe(timeline.endSeq);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -73,7 +71,6 @@ describe("daemon E2E - timeline window", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "canonical",
});
const assistantTexts = timeline.entries
@@ -82,7 +79,7 @@ describe("daemon E2E - timeline window", () => {
expect(assistantTexts.join("")).toBe(expected);
expect(timeline.hasOlder).toBe(true);
expect(timeline.startCursor?.seq).toBeGreaterThan(1);
expect(timeline.startSeq).toBeGreaterThan(1);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -289,7 +289,6 @@ async function resolveLatestAssistantMessage(
const timeline = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 300,
projection: "canonical",
});
for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) {
const entry = timeline.entries[idx];
@@ -0,0 +1,267 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type {
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
} from "../agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-database.js";
import { DbAgentSnapshotStore } from "./db-agent-snapshot-store.js";
import { agentSnapshots, projects, workspaces } from "./schema.js";
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
activeForegroundTurnId?: string | null;
runtimeInfo?: ManagedAgent["runtimeInfo"];
attention?: ManagedAgent["attention"];
};
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const now = overrides.updatedAt ?? new Date("2026-03-01T00:00:00.000Z");
const provider = overrides.provider ?? "codex";
const cwd = overrides.cwd ?? "/tmp/project";
const lifecycle = overrides.lifecycle ?? "idle";
const configOverrides = overrides.config ?? {};
const config: AgentSessionConfig = {
provider,
cwd,
title: configOverrides.title,
modeId: configOverrides.modeId ?? "plan",
model: configOverrides.model ?? "gpt-5.1-codex-mini",
extra: configOverrides.extra ?? { codex: { approvalPolicy: "on-request" } },
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "turn-1" : null);
return {
id: overrides.id ?? "agent-1",
provider,
cwd,
session,
capabilities: overrides.capabilities ?? {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
config,
lifecycle,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
availableModes: overrides.availableModes ?? [],
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
activeForegroundTurnId,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: overrides.timeline ?? [],
attention: overrides.attention ?? { requiresAttention: false },
runtimeInfo: overrides.runtimeInfo ?? {
provider,
sessionId: overrides.sessionId ?? "session-123",
model: config.model ?? null,
modeId: config.modeId ?? null,
},
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
lastUsage: overrides.lastUsage,
lastError: overrides.lastError,
internal: overrides.internal,
labels: overrides.labels ?? {},
pendingReplacement: false,
provisionalAssistantText: null,
};
}
function createStoredAgentRecord(overrides: Partial<StoredAgentRecord> = {}): StoredAgentRecord {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: "2026-03-01T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: {
modeId: "plan",
model: "gpt-5.1-codex-mini",
},
runtimeInfo: {
provider: "codex",
sessionId: "session-123",
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
persistence: {
provider: "codex",
sessionId: "session-123",
},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
...overrides,
};
}
describe("DbAgentSnapshotStore", () => {
let tmpDir: string;
let dataDir: string;
let database: PaseoDatabaseHandle;
let store: DbAgentSnapshotStore;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-agent-snapshot-store-"));
dataDir = path.join(tmpDir, "db");
database = await openPaseoDatabase(dataDir);
store = new DbAgentSnapshotStore(database.db);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("supports list/get/upsert/remove CRUD lifecycle", async () => {
await seedWorkspace(database, { workspaceId: "/tmp/project", projectId: "project-1" });
const record = createStoredAgentRecord();
expect(await store.list()).toEqual([]);
expect(await store.get(record.id)).toBeNull();
await store.upsert(record);
expect(await store.get(record.id)).toEqual(record);
expect(await store.list()).toEqual([record]);
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
workspaceId: "/tmp/project",
requiresAttention: false,
internal: false,
}),
]);
await store.remove(record.id);
expect(await store.get(record.id)).toBeNull();
expect(await store.list()).toEqual([]);
});
test("applySnapshot preserves title, createdAt, and archivedAt across updates", async () => {
await store.upsert(
createStoredAgentRecord({
id: "agent-apply",
title: "Pinned title",
createdAt: "2026-03-01T00:00:00.000Z",
archivedAt: "2026-03-05T00:00:00.000Z",
}),
);
await store.applySnapshot(
createManagedAgent({
id: "agent-apply",
createdAt: new Date("2026-03-10T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
lifecycle: "running",
}),
);
expect(await store.get("agent-apply")).toEqual(
expect.objectContaining({
id: "agent-apply",
title: "Pinned title",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-11T00:00:00.000Z",
archivedAt: "2026-03-05T00:00:00.000Z",
lastStatus: "running",
}),
);
});
test("setTitle throws for missing agents and updates existing agents", async () => {
await expect(store.setTitle("missing-agent", "Missing")).rejects.toThrow(
"Agent missing-agent not found",
);
await store.upsert(createStoredAgentRecord({ id: "agent-title", title: null }));
await store.setTitle("agent-title", "Renamed agent");
expect(await store.get("agent-title")).toEqual(
expect.objectContaining({
id: "agent-title",
title: "Renamed agent",
}),
);
});
test("upsert is idempotent for the same agent ID", async () => {
await store.upsert(createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }));
await store.upsert(
createStoredAgentRecord({
id: "agent-idempotent",
title: "Updated",
updatedAt: "2026-03-02T00:00:00.000Z",
lastStatus: "running",
}),
);
expect(await store.list()).toEqual([
createStoredAgentRecord({
id: "agent-idempotent",
title: "Updated",
updatedAt: "2026-03-02T00:00:00.000Z",
lastStatus: "running",
}),
]);
expect(await database.db.select().from(agentSnapshots)).toHaveLength(1);
});
});
async function seedWorkspace(
database: PaseoDatabaseHandle,
options: { workspaceId: string; projectId: string },
): Promise<void> {
await database.db.insert(projects).values({
projectId: options.projectId,
rootPath: options.workspaceId,
kind: "git",
displayName: options.projectId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await database.db.insert(workspaces).values({
workspaceId: options.workspaceId,
projectId: options.projectId,
cwd: options.workspaceId,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
}
@@ -0,0 +1,174 @@
import { asc, eq } from "drizzle-orm";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js";
import { toStoredAgentRecord } from "../agent/agent-projections.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import { normalizeWorkspaceId } from "../workspace-registry-model.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { agentSnapshots, workspaces } from "./schema.js";
type AgentSnapshotRow = typeof agentSnapshots.$inferSelect;
type AgentSnapshotInsert = typeof agentSnapshots.$inferInsert;
export function toStoredAgentRecordFromRow(row: AgentSnapshotRow): StoredAgentRecord {
return {
id: row.agentId,
provider: row.provider,
cwd: row.cwd,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastActivityAt: row.lastActivityAt ?? undefined,
lastUserMessageAt: row.lastUserMessageAt ?? null,
title: row.title ?? null,
labels: row.labels,
lastStatus: row.lastStatus as StoredAgentRecord["lastStatus"],
lastModeId: row.lastModeId ?? null,
config: row.config ?? null,
runtimeInfo: row.runtimeInfo ?? undefined,
persistence: row.persistence ?? null,
requiresAttention: row.requiresAttention,
attentionReason: (row.attentionReason ?? null) as StoredAgentRecord["attentionReason"],
attentionTimestamp: row.attentionTimestamp ?? null,
internal: row.internal,
archivedAt: row.archivedAt ?? null,
};
}
export function toAgentSnapshotRowValues(options: {
record: StoredAgentRecord;
workspaceId: string | null;
}): AgentSnapshotInsert {
const { record, workspaceId } = options;
return {
agentId: record.id,
provider: record.provider,
workspaceId,
cwd: record.cwd,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
lastActivityAt: record.lastActivityAt ?? null,
lastUserMessageAt: record.lastUserMessageAt ?? null,
title: record.title ?? null,
labels: record.labels,
lastStatus: record.lastStatus,
lastModeId: record.lastModeId ?? null,
config: record.config ?? null,
runtimeInfo: record.runtimeInfo ?? null,
persistence: record.persistence ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
internal: record.internal ?? false,
archivedAt: record.archivedAt ?? null,
};
}
function toAgentSnapshotUpdateSet(values: AgentSnapshotInsert) {
return {
provider: values.provider,
workspaceId: values.workspaceId,
cwd: values.cwd,
createdAt: values.createdAt,
updatedAt: values.updatedAt,
lastActivityAt: values.lastActivityAt,
lastUserMessageAt: values.lastUserMessageAt,
title: values.title,
labels: values.labels,
lastStatus: values.lastStatus,
lastModeId: values.lastModeId,
config: values.config,
runtimeInfo: values.runtimeInfo,
persistence: values.persistence,
requiresAttention: values.requiresAttention,
attentionReason: values.attentionReason,
attentionTimestamp: values.attentionTimestamp,
internal: values.internal,
archivedAt: values.archivedAt,
} satisfies Omit<AgentSnapshotInsert, "agentId">;
}
export class DbAgentSnapshotStore implements AgentSnapshotStore {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async list(): Promise<StoredAgentRecord[]> {
const rows = await this.db
.select()
.from(agentSnapshots)
.orderBy(asc(agentSnapshots.createdAt), asc(agentSnapshots.agentId));
return rows.map(toStoredAgentRecordFromRow);
}
async get(agentId: string): Promise<StoredAgentRecord | null> {
const rows = await this.db
.select()
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, agentId))
.limit(1);
const row = rows[0];
return row ? toStoredAgentRecordFromRow(row) : null;
}
async upsert(record: StoredAgentRecord): Promise<void> {
const values = toAgentSnapshotRowValues({
record,
workspaceId: await this.resolveWorkspaceId(record.cwd),
});
await this.db
.insert(agentSnapshots)
.values(values)
.onConflictDoUpdate({
target: agentSnapshots.agentId,
set: toAgentSnapshotUpdateSet(values),
});
}
async remove(agentId: string): Promise<void> {
await this.db.delete(agentSnapshots).where(eq(agentSnapshots.agentId, agentId));
}
async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const existing = await this.get(agent.id);
const hasTitleOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const hasInternalOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
});
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
await this.upsert(record);
}
async setTitle(agentId: string, title: string): Promise<void> {
const record = await this.get(agentId);
if (!record) {
throw new Error(`Agent ${agentId} not found`);
}
await this.upsert({ ...record, title });
}
private async resolveWorkspaceId(cwd: string): Promise<string | null> {
const workspaceId = normalizeWorkspaceId(cwd);
const rows = await this.db
.select({ workspaceId: workspaces.workspaceId })
.from(workspaces)
.where(eq(workspaces.workspaceId, workspaceId))
.limit(1);
return rows[0]?.workspaceId ?? null;
}
}
@@ -0,0 +1,266 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentTimelineRow } from "../agent/agent-timeline-store-types.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-database.js";
import { DbAgentTimelineStore } from "./db-agent-timeline-store.js";
import { agentTimelineRows } from "./schema.js";
function createTimestamp(seq: number): string {
return new Date(Date.UTC(2026, 2, 1, 0, 0, seq)).toISOString();
}
function createTimelineItem(
type: Extract<AgentTimelineItem["type"], "assistant_message" | "user_message">,
value: string,
): AgentTimelineItem {
if (type === "user_message") {
return {
type,
text: `user-${value}`,
messageId: `message-${value}`,
};
}
return {
type,
text: `assistant-${value}`,
};
}
function createRow(seq: number, item?: AgentTimelineItem): AgentTimelineRow {
return {
seq,
timestamp: createTimestamp(seq),
item: item ?? createTimelineItem("assistant_message", String(seq)),
};
}
describe("DbAgentTimelineStore", () => {
let tmpDir: string;
let dataDir: string;
let database: PaseoDatabaseHandle;
let store: DbAgentTimelineStore;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-agent-timeline-store-"));
dataDir = path.join(tmpDir, "db");
database = await openPaseoDatabase(dataDir);
store = new DbAgentTimelineStore(database.db);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("appendCommitted assigns sequential seq numbers per agent", async () => {
expect(
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "1")),
).toEqual({
seq: 1,
timestamp: expect.any(String),
item: createTimelineItem("assistant_message", "1"),
});
expect(
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "2")),
).toEqual({
seq: 2,
timestamp: expect.any(String),
item: createTimelineItem("assistant_message", "2"),
});
expect(
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "3")),
).toEqual({
seq: 3,
timestamp: expect.any(String),
item: createTimelineItem("assistant_message", "3"),
});
});
test("appendCommitted for different agents has independent seq sequences", async () => {
const firstAgentFirstRow = await store.appendCommitted(
"agent-1",
createTimelineItem("assistant_message", "a1"),
);
const secondAgentFirstRow = await store.appendCommitted(
"agent-2",
createTimelineItem("assistant_message", "b1"),
);
const firstAgentSecondRow = await store.appendCommitted(
"agent-1",
createTimelineItem("assistant_message", "a2"),
);
expect(firstAgentFirstRow.seq).toBe(1);
expect(secondAgentFirstRow.seq).toBe(1);
expect(firstAgentSecondRow.seq).toBe(2);
});
test("fetchCommitted tail returns the last N rows", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await expect(
store.fetchCommitted("agent-1", {
direction: "tail",
limit: 2,
}),
).resolves.toEqual({
direction: "tail",
window: {
minSeq: 1,
maxSeq: 5,
nextSeq: 6,
},
hasOlder: true,
hasNewer: false,
rows: [createRow(4), createRow(5)],
});
});
test("fetchCommitted after-cursor returns rows after a given seq", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await expect(
store.fetchCommitted("agent-1", {
direction: "after",
cursor: { seq: 2 },
limit: 2,
}),
).resolves.toEqual({
direction: "after",
window: {
minSeq: 1,
maxSeq: 5,
nextSeq: 6,
},
hasOlder: true,
hasNewer: true,
rows: [createRow(3), createRow(4)],
});
});
test("fetchCommitted before-cursor returns rows before a given seq", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await expect(
store.fetchCommitted("agent-1", {
direction: "before",
cursor: { seq: 4 },
limit: 2,
}),
).resolves.toEqual({
direction: "before",
window: {
minSeq: 1,
maxSeq: 5,
nextSeq: 6,
},
hasOlder: true,
hasNewer: true,
rows: [createRow(2), createRow(3)],
});
});
test("getLatestCommittedSeq returns 0 for an unknown agent", async () => {
await expect(store.getLatestCommittedSeq("missing-agent")).resolves.toBe(0);
});
test("getLatestCommittedSeq returns the latest seq after appends", async () => {
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "1"));
await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "2"));
await expect(store.getLatestCommittedSeq("agent-1")).resolves.toBe(2);
});
test("deleteAgent removes all rows for the target agent", async () => {
await store.bulkInsert("agent-1", [createRow(1), createRow(2)]);
await store.bulkInsert("agent-2", [createRow(1)]);
await store.deleteAgent("agent-1");
await expect(store.getCommittedRows("agent-1")).resolves.toEqual([]);
await expect(store.getCommittedRows("agent-2")).resolves.toEqual([createRow(1)]);
});
test("bulkInsert preserves provided seq numbers", async () => {
const rows = [createRow(3), createRow(7)];
await store.bulkInsert("agent-1", rows);
await expect(store.getCommittedRows("agent-1")).resolves.toEqual(rows);
});
test("item_kind is populated from item.type", async () => {
await store.appendCommitted("agent-1", createTimelineItem("user_message", "kind-check"), {
timestamp: createTimestamp(1),
});
await expect(database.db.select().from(agentTimelineRows)).resolves.toEqual([
expect.objectContaining({
agentId: "agent-1",
seq: 1,
committedAt: createTimestamp(1),
itemKind: "user_message",
}),
]);
});
test("getLastItem returns the latest committed item", async () => {
await store.bulkInsert("agent-1", [
createRow(1, createTimelineItem("user_message", "1")),
createRow(2, createTimelineItem("assistant_message", "2")),
]);
await expect(store.getLastItem("agent-1")).resolves.toEqual(
createTimelineItem("assistant_message", "2"),
);
await expect(store.getLastItem("missing-agent")).resolves.toBeNull();
});
test("getLastAssistantMessage assembles the latest contiguous assistant chunks", async () => {
await store.bulkInsert("agent-1", [
createRow(1, createTimelineItem("assistant_message", "1")),
createRow(2, createTimelineItem("assistant_message", "2")),
createRow(3, { type: "reasoning", text: "separator-1" }),
createRow(4, createTimelineItem("assistant_message", "4")),
createRow(5, createTimelineItem("assistant_message", "5")),
createRow(6, { type: "reasoning", text: "separator-2" }),
]);
await expect(store.getLastAssistantMessage("agent-1")).resolves.toBe("assistant-4assistant-5");
await expect(store.getLastAssistantMessage("missing-agent")).resolves.toBeNull();
});
test("hasCommittedUserMessage matches by normalized messageId and text", async () => {
await store.bulkInsert("agent-1", [
createRow(1, createTimelineItem("user_message", "1")),
createRow(2, createTimelineItem("assistant_message", "2")),
]);
await expect(
store.hasCommittedUserMessage("agent-1", {
messageId: " message-1 ",
text: "user-1",
}),
).resolves.toBe(true);
await expect(
store.hasCommittedUserMessage("agent-1", {
messageId: "message-1",
text: "different",
}),
).resolves.toBe(false);
await expect(
store.hasCommittedUserMessage("agent-1", {
messageId: " ",
text: "user-1",
}),
).resolves.toBe(false);
});
});
@@ -0,0 +1,303 @@
import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
AgentTimelineStore,
AgentTimelineWindow,
} from "../agent/agent-timeline-store-types.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { agentTimelineRows } from "./schema.js";
type AgentTimelineRowRecord = typeof agentTimelineRows.$inferSelect;
type AgentTimelineRowInsert = typeof agentTimelineRows.$inferInsert;
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
if (typeof messageId !== "string") {
return undefined;
}
const normalized = messageId.trim();
return normalized.length > 0 ? normalized : undefined;
}
function toTimelineRow(row: AgentTimelineRowRecord): AgentTimelineRow {
return {
seq: row.seq,
timestamp: row.committedAt,
item: row.item,
};
}
function toInsertValues(agentId: string, row: AgentTimelineRow): AgentTimelineRowInsert {
return {
agentId,
seq: row.seq,
committedAt: row.timestamp,
item: row.item,
itemKind: row.item.type,
};
}
function normalizeFetchLimit(limit: number | undefined): number {
if (limit === undefined) {
return DEFAULT_TIMELINE_FETCH_LIMIT;
}
return Math.max(0, Math.floor(limit));
}
export class DbAgentTimelineStore implements AgentTimelineStore {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async appendCommitted(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): Promise<AgentTimelineRow> {
const nextSeq = (await this.getMaxSeq(agentId)) + 1;
const row: AgentTimelineRow = {
seq: nextSeq,
timestamp: options?.timestamp ?? new Date().toISOString(),
item,
};
await this.db.insert(agentTimelineRows).values(toInsertValues(agentId, row));
return row;
}
async fetchCommitted(
agentId: string,
options?: AgentTimelineFetchOptions,
): Promise<AgentTimelineFetchResult> {
const direction = options?.direction ?? "tail";
const limit = normalizeFetchLimit(options?.limit);
const selectAll = limit === 0;
const window = await this.getWindow(agentId);
if (window.maxSeq === 0) {
return {
direction,
window,
hasOlder: false,
hasNewer: false,
rows: [],
};
}
if (direction === "tail") {
const rows = selectAll
? await this.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId))
.orderBy(asc(agentTimelineRows.seq))
: (
await this.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId))
.orderBy(desc(agentTimelineRows.seq))
.limit(limit)
).reverse();
const selected = rows.map(toTimelineRow);
return {
direction,
window,
hasOlder: selected.length > 0 && selected[0]!.seq > window.minSeq,
hasNewer: false,
rows: selected,
};
}
if (direction === "after") {
const baseSeq = options?.cursor?.seq ?? 0;
const rows = (
selectAll
? await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)))
.orderBy(asc(agentTimelineRows.seq))
: await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)))
.orderBy(asc(agentTimelineRows.seq))
.limit(limit)
).map(toTimelineRow);
if (rows.length === 0) {
return {
direction,
window,
hasOlder: baseSeq >= window.minSeq,
hasNewer: false,
rows,
};
}
const lastSelected = rows[rows.length - 1]!;
return {
direction,
window,
hasOlder: rows[0]!.seq > window.minSeq,
hasNewer: lastSelected.seq < window.maxSeq,
rows,
};
}
const beforeSeq = options?.cursor?.seq ?? window.nextSeq;
const rows = (
selectAll
? await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)))
.orderBy(asc(agentTimelineRows.seq))
: (
await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)))
.orderBy(desc(agentTimelineRows.seq))
.limit(limit)
).reverse()
).map(toTimelineRow);
return {
direction,
window,
hasOlder: rows.length > 0 && rows[0]!.seq > window.minSeq,
hasNewer: beforeSeq <= window.maxSeq,
rows,
};
}
async getLatestCommittedSeq(agentId: string): Promise<number> {
return this.getMaxSeq(agentId);
}
async getCommittedRows(agentId: string): Promise<AgentTimelineRow[]> {
const rows = await this.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId))
.orderBy(asc(agentTimelineRows.seq));
return rows.map(toTimelineRow);
}
async getLastItem(agentId: string): Promise<AgentTimelineItem | null> {
const [row] = await this.db
.select({ item: agentTimelineRows.item })
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId))
.orderBy(desc(agentTimelineRows.seq))
.limit(1);
return row?.item ?? null;
}
async getLastAssistantMessage(agentId: string): Promise<string | null> {
const rows = await this.db
.select({
seq: agentTimelineRows.seq,
item: agentTimelineRows.item,
})
.from(agentTimelineRows)
.where(
and(
eq(agentTimelineRows.agentId, agentId),
eq(agentTimelineRows.itemKind, "assistant_message"),
),
)
.orderBy(desc(agentTimelineRows.seq));
if (rows.length === 0) {
return null;
}
const chunks: string[] = [];
let previousSeq: number | null = null;
for (const row of rows) {
if (previousSeq !== null && row.seq !== previousSeq - 1) {
break;
}
if (row.item.type !== "assistant_message") {
break;
}
chunks.push(row.item.text);
previousSeq = row.seq;
}
return chunks.length > 0 ? chunks.reverse().join("") : null;
}
async hasCommittedUserMessage(
agentId: string,
options: { messageId: string; text: string },
): Promise<boolean> {
const messageId = normalizeTimelineMessageId(options.messageId);
if (!messageId) {
return false;
}
const [row] = await this.db
.select({ seq: agentTimelineRows.seq })
.from(agentTimelineRows)
.where(
and(
eq(agentTimelineRows.agentId, agentId),
eq(agentTimelineRows.itemKind, "user_message"),
sql`${agentTimelineRows.item} ->> 'messageId' = ${messageId}`,
sql`${agentTimelineRows.item} ->> 'text' = ${options.text}`,
),
)
.limit(1);
return row !== undefined;
}
async deleteAgent(agentId: string): Promise<void> {
await this.db.delete(agentTimelineRows).where(eq(agentTimelineRows.agentId, agentId));
}
async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void> {
if (rows.length === 0) {
return;
}
await this.db.insert(agentTimelineRows).values(rows.map((row) => toInsertValues(agentId, row)));
}
private async getMaxSeq(agentId: string): Promise<number> {
const [row] = await this.db
.select({
maxSeq: sql<number>`coalesce(max(${agentTimelineRows.seq}), 0)`,
})
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId));
return Number(row?.maxSeq ?? 0);
}
private async getWindow(agentId: string): Promise<AgentTimelineWindow> {
const [row] = await this.db
.select({
minSeq: sql<number>`coalesce(min(${agentTimelineRows.seq}), 0)`,
maxSeq: sql<number>`coalesce(max(${agentTimelineRows.seq}), 0)`,
})
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId));
const minSeq = Number(row?.minSeq ?? 0);
const maxSeq = Number(row?.maxSeq ?? 0);
return {
minSeq,
maxSeq,
nextSeq: maxSeq + 1,
};
}
}
@@ -0,0 +1,73 @@
import { eq } from "drizzle-orm";
import type { PersistedProjectKind } from "../workspace-registry-model.js";
import type { ProjectRegistry, PersistedProjectRecord } from "../workspace-registry.js";
import { createPersistedProjectRecord } from "../workspace-registry.js";
import { projects } from "./schema.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
function toPersistedProjectRecord(row: typeof projects.$inferSelect): PersistedProjectRecord {
return createPersistedProjectRecord({
...row,
kind: row.kind as PersistedProjectKind,
});
}
export class DbProjectRegistry implements ProjectRegistry {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async initialize(): Promise<void> {
return Promise.resolve();
}
async existsOnDisk(): Promise<boolean> {
return true;
}
async list(): Promise<PersistedProjectRecord[]> {
const rows = await this.db.select().from(projects);
return rows.map(toPersistedProjectRecord);
}
async get(projectId: string): Promise<PersistedProjectRecord | null> {
const rows = await this.db.select().from(projects).where(eq(projects.projectId, projectId)).limit(1);
const row = rows[0];
return row ? toPersistedProjectRecord(row) : null;
}
async upsert(record: PersistedProjectRecord): Promise<void> {
const nextRecord = createPersistedProjectRecord(record);
await this.db
.insert(projects)
.values(nextRecord)
.onConflictDoUpdate({
target: projects.projectId,
set: {
rootPath: nextRecord.rootPath,
kind: nextRecord.kind,
displayName: nextRecord.displayName,
createdAt: nextRecord.createdAt,
updatedAt: nextRecord.updatedAt,
archivedAt: nextRecord.archivedAt,
},
});
}
async archive(projectId: string, archivedAt: string): Promise<void> {
await this.db
.update(projects)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(projects.projectId, projectId));
}
async remove(projectId: string): Promise<void> {
await this.db.delete(projects).where(eq(projects.projectId, projectId));
}
}
@@ -0,0 +1,154 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { PersistedProjectRecord, PersistedWorkspaceRecord, ProjectRegistry, WorkspaceRegistry } from "../workspace-registry.js";
import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from "../workspace-registry.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-database.js";
import { DbProjectRegistry } from "./db-project-registry.js";
import { DbWorkspaceRegistry } from "./db-workspace-registry.js";
function createProjectRecord(input: Partial<PersistedProjectRecord> = {}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: "remote:github.com/acme/repo",
rootPath: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
...input,
});
}
function createWorkspaceRecord(input: Partial<PersistedWorkspaceRecord> = {}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "remote:github.com/acme/repo",
cwd: "/tmp/repo",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
...input,
});
}
describe("DB-backed workspace registries", () => {
let tmpDir: string;
let dataDir: string;
let database: PaseoDatabaseHandle;
let projectRegistry: ProjectRegistry;
let workspaceRegistry: WorkspaceRegistry;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-workspace-registry-"));
dataDir = path.join(tmpDir, "db");
database = await openPaseoDatabase(dataDir);
projectRegistry = new DbProjectRegistry(database.db);
workspaceRegistry = new DbWorkspaceRegistry(database.db);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("project registry matches the file-backed behavioral contract", async () => {
await projectRegistry.initialize();
expect(await projectRegistry.existsOnDisk()).toBe(true);
expect(await projectRegistry.get("missing-project")).toBeNull();
expect(await projectRegistry.list()).toEqual([]);
await projectRegistry.upsert(createProjectRecord());
await projectRegistry.upsert(
createProjectRecord({
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await projectRegistry.archive(
"remote:github.com/acme/repo",
"2026-03-03T00:00:00.000Z",
);
await projectRegistry.archive("missing-project", "2026-03-04T00:00:00.000Z");
expect(await projectRegistry.get("remote:github.com/acme/repo")).toEqual(
createProjectRecord({
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
);
expect(await projectRegistry.list()).toEqual([
createProjectRecord({
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
]);
await projectRegistry.remove("missing-project");
await projectRegistry.remove("remote:github.com/acme/repo");
expect(await projectRegistry.get("remote:github.com/acme/repo")).toBeNull();
expect(await projectRegistry.list()).toEqual([]);
});
test("workspace registry matches the file-backed behavioral contract", async () => {
await workspaceRegistry.initialize();
expect(await workspaceRegistry.existsOnDisk()).toBe(true);
expect(await workspaceRegistry.get("missing-workspace")).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
await projectRegistry.upsert(createProjectRecord());
await workspaceRegistry.upsert(createWorkspaceRecord());
await workspaceRegistry.upsert(
createWorkspaceRecord({
displayName: "feature/workspace",
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await workspaceRegistry.archive("/tmp/repo", "2026-03-03T00:00:00.000Z");
await workspaceRegistry.archive("missing-workspace", "2026-03-04T00:00:00.000Z");
expect(await workspaceRegistry.get("/tmp/repo")).toEqual(
createWorkspaceRecord({
displayName: "feature/workspace",
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
);
expect(await workspaceRegistry.list()).toEqual([
createWorkspaceRecord({
displayName: "feature/workspace",
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
]);
await workspaceRegistry.remove("missing-workspace");
await workspaceRegistry.remove("/tmp/repo");
expect(await workspaceRegistry.get("/tmp/repo")).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
});
test("rejects workspace upserts for non-existent projects", async () => {
await expect(
workspaceRegistry.upsert(
createWorkspaceRecord({
projectId: "remote:github.com/acme/missing",
}),
),
).rejects.toThrow();
});
test("rejects project removal while linked workspaces exist", async () => {
await projectRegistry.upsert(createProjectRecord());
await workspaceRegistry.upsert(createWorkspaceRecord());
await expect(projectRegistry.remove("remote:github.com/acme/repo")).rejects.toThrow();
expect(await projectRegistry.get("remote:github.com/acme/repo")).toEqual(createProjectRecord());
});
});
@@ -0,0 +1,78 @@
import { eq } from "drizzle-orm";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { workspaces } from "./schema.js";
import type { PersistedWorkspaceKind } from "../workspace-registry-model.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js";
import { createPersistedWorkspaceRecord } from "../workspace-registry.js";
function toPersistedWorkspaceRecord(row: typeof workspaces.$inferSelect): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
...row,
kind: row.kind as PersistedWorkspaceKind,
});
}
export class DbWorkspaceRegistry implements WorkspaceRegistry {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async initialize(): Promise<void> {
return Promise.resolve();
}
async existsOnDisk(): Promise<boolean> {
return true;
}
async list(): Promise<PersistedWorkspaceRecord[]> {
const rows = await this.db.select().from(workspaces);
return rows.map(toPersistedWorkspaceRecord);
}
async get(workspaceId: string): Promise<PersistedWorkspaceRecord | null> {
const rows = await this.db
.select()
.from(workspaces)
.where(eq(workspaces.workspaceId, workspaceId))
.limit(1);
const row = rows[0];
return row ? toPersistedWorkspaceRecord(row) : null;
}
async upsert(record: PersistedWorkspaceRecord): Promise<void> {
const nextRecord = createPersistedWorkspaceRecord(record);
await this.db
.insert(workspaces)
.values(nextRecord)
.onConflictDoUpdate({
target: workspaces.workspaceId,
set: {
projectId: nextRecord.projectId,
cwd: nextRecord.cwd,
kind: nextRecord.kind,
displayName: nextRecord.displayName,
createdAt: nextRecord.createdAt,
updatedAt: nextRecord.updatedAt,
archivedAt: nextRecord.archivedAt,
},
});
}
async archive(workspaceId: string, archivedAt: string): Promise<void> {
await this.db
.update(workspaces)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(workspaces.workspaceId, workspaceId));
}
async remove(workspaceId: string): Promise<void> {
await this.db.delete(workspaces).where(eq(workspaces.workspaceId, workspaceId));
}
}
@@ -0,0 +1,173 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-database.js";
import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js";
import { agentSnapshots } from "./schema.js";
describe("importLegacyAgentSnapshots", () => {
let tmpDir: string;
let paseoHome: string;
let dbDir: string;
let database: PaseoDatabaseHandle;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-agent-import-"));
paseoHome = path.join(tmpDir, ".paseo");
dbDir = path.join(paseoHome, "db");
mkdirSync(paseoHome, { recursive: true });
database = await openPaseoDatabase(dbDir);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("imports agent JSON files when the DB is empty", async () => {
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/agent-1.json",
payload: createLegacyAgentJson({
requiresAttention: undefined,
internal: undefined,
}),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedAgents: 1,
});
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/project",
requiresAttention: false,
internal: false,
}),
]);
});
test("skips import when the DB already has agent data", async () => {
await database.db.insert(agentSnapshots).values({
agentId: "existing-agent",
provider: "codex",
workspaceId: null,
cwd: "/tmp/existing-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: "2026-03-01T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: null,
runtimeInfo: { provider: "codex", sessionId: "session-existing" },
persistence: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
});
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/legacy-agent.json",
payload: createLegacyAgentJson({ id: "legacy-agent" }),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "skipped",
reason: "database-not-empty",
});
expect(await database.db.select().from(agentSnapshots)).toHaveLength(1);
});
test("imports agent JSON files from nested project directories", async () => {
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/agent-root.json",
payload: createLegacyAgentJson({ id: "agent-root", cwd: "/tmp/root-project" }),
});
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/tmp-nested-project/agent-nested.json",
payload: createLegacyAgentJson({ id: "agent-nested", cwd: "/tmp/nested-project" }),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedAgents: 2,
});
expect(
(await database.db.select().from(agentSnapshots)).map((row) => row.agentId).sort(),
).toEqual(["agent-nested", "agent-root"]);
});
});
function createLegacyAgentJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: {
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
runtimeInfo: {
provider: "codex",
sessionId: "session-123",
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
persistence: null,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
...overrides,
};
}
function writeLegacyAgentJson(input: {
paseoHome: string;
relativePath: string;
payload: Record<string, unknown>;
}): void {
const absolutePath = path.join(input.paseoHome, input.relativePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, JSON.stringify(input.payload, null, 2), {
encoding: "utf8",
flag: "w",
});
}
@@ -0,0 +1,128 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { count } from "drizzle-orm";
import type { Logger } from "pino";
import { parseStoredAgentRecord, type StoredAgentRecord } from "../agent/agent-storage.js";
import { normalizeWorkspaceId } from "../workspace-registry-model.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { toAgentSnapshotRowValues } from "./db-agent-snapshot-store.js";
import { agentSnapshots, workspaces } from "./schema.js";
export type LegacyAgentSnapshotImportResult =
| {
status: "imported";
importedAgents: number;
}
| {
status: "skipped";
reason: "database-not-empty" | "no-legacy-files";
};
export async function importLegacyAgentSnapshots(options: {
db: PaseoDatabaseHandle["db"];
paseoHome: string;
logger: Logger;
}): Promise<LegacyAgentSnapshotImportResult> {
if (await hasAnyAgentSnapshotRows(options.db)) {
options.logger.info("Skipping legacy agent snapshot import because the DB is not empty");
return {
status: "skipped",
reason: "database-not-empty",
};
}
const records = await readLegacyAgentRecords(path.join(options.paseoHome, "agents"), options.logger);
if (records.length === 0) {
options.logger.info("Skipping legacy agent snapshot import because no legacy files exist");
return {
status: "skipped",
reason: "no-legacy-files",
};
}
await options.db.transaction(async (tx) => {
const workspaceRows = await tx.select({ workspaceId: workspaces.workspaceId }).from(workspaces);
const workspaceIds = new Set(workspaceRows.map((row) => row.workspaceId));
const rows = records.map((record) => {
const workspaceId = normalizeWorkspaceId(record.cwd);
return toAgentSnapshotRowValues({
record,
workspaceId: workspaceIds.has(workspaceId) ? workspaceId : null,
});
});
await tx.insert(agentSnapshots).values(rows);
});
options.logger.info(
{ importedAgents: records.length },
"Imported legacy agent snapshots into the database",
);
return {
status: "imported",
importedAgents: records.length,
};
}
async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise<StoredAgentRecord[]> {
let entries: Array<import("node:fs").Dirent> = [];
try {
entries = await fs.readdir(baseDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
const recordsById = new Map<string, StoredAgentRecord>();
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith(".json")) {
const record = await readRecordFile(path.join(baseDir, entry.name), logger);
if (record) {
recordsById.set(record.id, record);
}
continue;
}
if (!entry.isDirectory()) {
continue;
}
let childEntries: Array<import("node:fs").Dirent> = [];
try {
childEntries = await fs.readdir(path.join(baseDir, entry.name), { withFileTypes: true });
} catch {
continue;
}
for (const childEntry of childEntries) {
if (!childEntry.isFile() || !childEntry.name.endsWith(".json")) {
continue;
}
const record = await readRecordFile(path.join(baseDir, entry.name, childEntry.name), logger);
if (record) {
recordsById.set(record.id, record);
}
}
}
return Array.from(recordsById.values());
}
async function readRecordFile(filePath: string, logger: Logger): Promise<StoredAgentRecord | null> {
try {
const raw = await fs.readFile(filePath, "utf8");
return parseStoredAgentRecord(JSON.parse(raw));
} catch (error) {
logger.error({ err: error, filePath }, "Skipping invalid legacy agent snapshot");
return null;
}
}
async function hasAnyAgentSnapshotRows(db: PaseoDatabaseHandle["db"]): Promise<boolean> {
const rows = await db.select({ count: count() }).from(agentSnapshots);
return (rows[0]?.count ?? 0) > 0;
}
@@ -0,0 +1,191 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-database.js";
import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js";
import { projects, workspaces } from "./schema.js";
describe("importLegacyProjectWorkspaceJson", () => {
let tmpDir: string;
let paseoHome: string;
let dbDir: string;
let database: PaseoDatabaseHandle;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-import-"));
paseoHome = path.join(tmpDir, ".paseo");
dbDir = path.join(paseoHome, "db");
mkdirSync(paseoHome, { recursive: true });
database = await openPaseoDatabase(dbDir);
});
afterEach(async () => {
await database?.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("imports legacy projects and workspaces once when the DB is empty", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
const result = await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedProjects: 1,
importedWorkspaces: 1,
});
expect(await database.db.select().from(projects)).toEqual([
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
]);
expect(await database.db.select().from(workspaces)).toEqual([
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
]);
});
test("skips import when the DB already has project or workspace data", async () => {
await database.db.insert(projects).values({
projectId: "existing-project",
rootPath: "/tmp/existing-project",
kind: "git",
displayName: "Existing Project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "legacy-project",
rootPath: "/tmp/legacy-project",
kind: "git",
displayName: "Legacy Project",
createdAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [],
});
const result = await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "skipped",
reason: "database-not-empty",
});
expect(
await database.db.select().from(projects).where(eq(projects.projectId, "legacy-project")),
).toEqual([]);
});
test("rolls back the whole import when workspace insertion fails", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [
{
workspaceId: "workspace-1",
projectId: "missing-project",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
await expect(
importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
}),
).rejects.toThrow();
expect(await database.db.select().from(projects)).toEqual([]);
expect(await database.db.select().from(workspaces)).toEqual([]);
});
});
function writeLegacyJson(input: {
paseoHome: string;
projectsJson: unknown[];
workspacesJson: unknown[];
}): void {
const projectsPath = path.join(input.paseoHome, "projects", "projects.json");
const workspacesPath = path.join(input.paseoHome, "projects", "workspaces.json");
mkdirSync(path.dirname(projectsPath), { recursive: true });
writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), { encoding: "utf8", flag: "w" });
writeFileSync(workspacesPath, JSON.stringify(input.workspacesJson, null, 2), {
encoding: "utf8",
flag: "w",
});
}
@@ -0,0 +1,109 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { count } from "drizzle-orm";
import type { Logger } from "pino";
import {
parsePersistedProjectRecords,
parsePersistedWorkspaceRecords,
} from "../workspace-registry.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { projects, workspaces } from "./schema.js";
export type LegacyProjectWorkspaceImportResult =
| {
status: "imported";
importedProjects: number;
importedWorkspaces: number;
}
| {
status: "skipped";
reason: "database-not-empty" | "no-legacy-files";
};
export async function importLegacyProjectWorkspaceJson(options: {
db: PaseoDatabaseHandle["db"];
paseoHome: string;
logger: Logger;
}): Promise<LegacyProjectWorkspaceImportResult> {
const projectsPath = path.join(options.paseoHome, "projects", "projects.json");
const workspacesPath = path.join(options.paseoHome, "projects", "workspaces.json");
const [projectRows, workspaceRows, databaseHasRows] = await Promise.all([
readLegacyProjects(projectsPath),
readLegacyWorkspaces(workspacesPath),
hasAnyProjectWorkspaceRows(options.db),
]);
if (databaseHasRows) {
options.logger.info("Skipping legacy project/workspace JSON import because the DB is not empty");
return {
status: "skipped",
reason: "database-not-empty",
};
}
if (projectRows.length === 0 && workspaceRows.length === 0) {
options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist");
return {
status: "skipped",
reason: "no-legacy-files",
};
}
await options.db.transaction(async (tx) => {
if (projectRows.length > 0) {
await tx.insert(projects).values(projectRows);
}
if (workspaceRows.length > 0) {
await tx.insert(workspaces).values(workspaceRows);
}
});
options.logger.info(
{
importedProjects: projectRows.length,
importedWorkspaces: workspaceRows.length,
},
"Imported legacy project/workspace JSON into the database",
);
return {
status: "imported",
importedProjects: projectRows.length,
importedWorkspaces: workspaceRows.length,
};
}
async function readLegacyProjects(filePath: string) {
const raw = await readOptionalJsonFile(filePath);
return raw ? parsePersistedProjectRecords(raw) : [];
}
async function readLegacyWorkspaces(filePath: string) {
const raw = await readOptionalJsonFile(filePath);
return raw ? parsePersistedWorkspaceRecords(raw) : [];
}
async function readOptionalJsonFile(filePath: string): Promise<unknown | null> {
try {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return null;
}
throw error;
}
}
async function hasAnyProjectWorkspaceRows(db: PaseoDatabaseHandle["db"]): Promise<boolean> {
const [projectCountRows, workspaceCountRows] = await Promise.all([
db.select({ count: count() }).from(projects),
db.select({ count: count() }).from(workspaces),
]);
const projectCount = projectCountRows[0]?.count ?? 0;
const workspaceCount = workspaceCountRows[0]?.count ?? 0;
return projectCount > 0 || workspaceCount > 0;
}
@@ -0,0 +1,11 @@
import { fileURLToPath } from "node:url";
import type { PgliteDatabase } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
const migrationsFolder = fileURLToPath(new URL("./migrations", import.meta.url));
export async function runPaseoDbMigrations(db: PgliteDatabase<typeof import("./schema.js").paseoDbSchema>): Promise<void> {
await migrate(db, { migrationsFolder });
}
@@ -0,0 +1,56 @@
CREATE TABLE "agent_snapshots" (
"agent_id" text PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"workspace_id" text,
"cwd" text NOT NULL,
"created_at" text NOT NULL,
"updated_at" text NOT NULL,
"last_activity_at" text,
"last_user_message_at" text,
"title" text,
"labels" jsonb NOT NULL,
"last_status" text NOT NULL,
"last_mode_id" text,
"config" jsonb,
"runtime_info" jsonb,
"persistence" jsonb,
"requires_attention" boolean NOT NULL,
"attention_reason" text,
"attention_timestamp" text,
"internal" boolean NOT NULL,
"archived_at" text
);
--> statement-breakpoint
CREATE TABLE "agent_timeline_rows" (
"agent_id" text NOT NULL,
"seq" integer NOT NULL,
"committed_at" text NOT NULL,
"item" jsonb NOT NULL,
"item_kind" text,
CONSTRAINT "agent_timeline_rows_pk" PRIMARY KEY("agent_id","seq")
);
--> statement-breakpoint
CREATE TABLE "projects" (
"project_id" text PRIMARY KEY NOT NULL,
"root_path" text NOT NULL,
"kind" text NOT NULL,
"display_name" text NOT NULL,
"created_at" text NOT NULL,
"updated_at" text NOT NULL,
"archived_at" text
);
--> statement-breakpoint
CREATE TABLE "workspaces" (
"workspace_id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"cwd" text NOT NULL,
"kind" text NOT NULL,
"display_name" text NOT NULL,
"created_at" text NOT NULL,
"updated_at" text NOT NULL,
"archived_at" text
);
--> statement-breakpoint
ALTER TABLE "agent_snapshots" ADD CONSTRAINT "agent_snapshots_workspace_id_workspaces_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("workspace_id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
CREATE INDEX "workspaces_project_id_idx" ON "workspaces" USING btree ("project_id");
@@ -0,0 +1,363 @@
{
"id": "5940c904-fd8b-4276-953d-acc382bf3fb8",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent_snapshots": {
"name": "agent_snapshots",
"schema": "",
"columns": {
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cwd": {
"name": "cwd",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_activity_at": {
"name": "last_activity_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_user_message_at": {
"name": "last_user_message_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"labels": {
"name": "labels",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"last_status": {
"name": "last_status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_mode_id": {
"name": "last_mode_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"runtime_info": {
"name": "runtime_info",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"persistence": {
"name": "persistence",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"requires_attention": {
"name": "requires_attention",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"attention_reason": {
"name": "attention_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"attention_timestamp": {
"name": "attention_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false
},
"internal": {
"name": "internal",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"agent_snapshots_workspace_id_workspaces_workspace_id_fk": {
"name": "agent_snapshots_workspace_id_workspaces_workspace_id_fk",
"tableFrom": "agent_snapshots",
"tableTo": "workspaces",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"workspace_id"
],
"onDelete": "set null",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_timeline_rows": {
"name": "agent_timeline_rows",
"schema": "",
"columns": {
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seq": {
"name": "seq",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"committed_at": {
"name": "committed_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"item": {
"name": "item",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"item_kind": {
"name": "item_kind",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"agent_timeline_rows_pk": {
"name": "agent_timeline_rows_pk",
"columns": [
"agent_id",
"seq"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"root_path": {
"name": "root_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workspaces": {
"name": "workspaces",
"schema": "",
"columns": {
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cwd": {
"name": "cwd",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"workspaces_project_id_idx": {
"name": "workspaces_project_id_idx",
"columns": [
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"workspaces_project_id_projects_project_id_fk": {
"name": "workspaces_project_id_projects_project_id_fk",
"tableFrom": "workspaces",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"project_id"
],
"onDelete": "restrict",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1774405361702,
"tag": "0000_youthful_lady_mastermind",
"breakpoints": true
}
]
}
@@ -0,0 +1,264 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { openPaseoDatabase } from "./pglite-database.js";
import { runPaseoDbMigrations } from "./migrations.js";
import {
agentSnapshots,
agentTimelineRows,
projects,
workspaces,
} from "./schema.js";
function createTimestamp(day: number): string {
return `2026-03-${String(day).padStart(2, "0")}T00:00:00.000Z`;
}
function createTimelineItem(type: AgentTimelineItem["type"], suffix: string): AgentTimelineItem {
if (type === "user_message") {
return { type, text: `user-${suffix}`, messageId: `msg-${suffix}` };
}
if (type === "assistant_message" || type === "reasoning") {
return { type, text: `${type}-${suffix}` };
}
return { type: "error", message: `error-${suffix}` };
}
describe("PGlite database contract", () => {
let tmpDir: string;
let dataDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-db-"));
dataDir = path.join(tmpDir, "db");
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("creates, migrates, closes, and reopens a persistent database", async () => {
const database = await openPaseoDatabase(dataDir);
await database.db.insert(projects).values({
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
});
await database.close();
const reopened = await openPaseoDatabase(dataDir);
const rows = await reopened.db.select().from(projects);
expect(rows).toEqual([
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
},
]);
await reopened.close();
});
test("supports project and workspace linkage plus archive field updates", async () => {
const database = await openPaseoDatabase(dataDir);
await database.db.insert(projects).values({
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
});
await database.db.insert(workspaces).values({
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
});
await database.db
.update(workspaces)
.set({ archivedAt: createTimestamp(2), updatedAt: createTimestamp(2) })
.where(eq(workspaces.workspaceId, "workspace-1"));
const linkedRows = await database.db
.select({
projectId: projects.projectId,
workspaceId: workspaces.workspaceId,
workspaceArchivedAt: workspaces.archivedAt,
})
.from(workspaces)
.innerJoin(projects, eq(workspaces.projectId, projects.projectId));
expect(linkedRows).toEqual([
{
projectId: "project-1",
workspaceId: "workspace-1",
workspaceArchivedAt: createTimestamp(2),
},
]);
await database.close();
});
test("supports snapshot insert, get, and update with the current persisted metadata shape", async () => {
const database = await openPaseoDatabase(dataDir);
await database.db.insert(agentSnapshots).values({
agentId: "agent-1",
provider: "codex",
workspaceId: null,
cwd: "/tmp/project-1",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
lastActivityAt: createTimestamp(1),
lastUserMessageAt: null,
title: "Agent One",
labels: { surface: "workspace" },
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1", modeId: "plan" },
runtimeInfo: { provider: "codex", sessionId: "session-1" },
persistence: { provider: "codex", sessionId: "session-1" },
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
});
await database.db
.update(agentSnapshots)
.set({
updatedAt: createTimestamp(2),
lastStatus: "running",
title: "Agent One Updated",
archivedAt: createTimestamp(3),
})
.where(eq(agentSnapshots.agentId, "agent-1"));
const rows = await database.db
.select()
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, "agent-1"));
expect(rows).toEqual([
{
agentId: "agent-1",
provider: "codex",
workspaceId: null,
cwd: "/tmp/project-1",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(2),
lastActivityAt: createTimestamp(1),
lastUserMessageAt: null,
title: "Agent One Updated",
labels: { surface: "workspace" },
lastStatus: "running",
lastModeId: "plan",
config: { model: "gpt-5.1", modeId: "plan" },
runtimeInfo: { provider: "codex", sessionId: "session-1" },
persistence: { provider: "codex", sessionId: "session-1" },
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: createTimestamp(3),
},
]);
await database.close();
});
test("supports timeline append and tail, after-seq, before-seq access patterns in committed order", async () => {
const database = await openPaseoDatabase(dataDir);
const rows = [1, 2, 3, 4].map((seq) => ({
agentId: "agent-1",
seq,
committedAt: createTimestamp(seq),
item: createTimelineItem(seq === 1 ? "user_message" : "assistant_message", String(seq)),
itemKind: seq === 1 ? "user_message" : "assistant_message",
}));
await database.db.insert(agentTimelineRows).values(rows);
const tailRows = await database.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, "agent-1"))
.orderBy(desc(agentTimelineRows.seq))
.limit(2);
expect(tailRows.map((row) => row.seq).reverse()).toEqual([3, 4]);
const afterRows = await database.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, "agent-1"), gt(agentTimelineRows.seq, 2)))
.orderBy(asc(agentTimelineRows.seq));
expect(afterRows.map((row) => row.seq)).toEqual([3, 4]);
const beforeRows = await database.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, "agent-1"), lt(agentTimelineRows.seq, 4)))
.orderBy(desc(agentTimelineRows.seq))
.limit(2);
expect(beforeRows.map((row) => row.seq).reverse()).toEqual([2, 3]);
await database.close();
});
test("enforces per-agent seq uniqueness and reruns migrations without drift", async () => {
const database = await openPaseoDatabase(dataDir);
await database.db.insert(agentTimelineRows).values({
agentId: "agent-1",
seq: 1,
committedAt: createTimestamp(1),
item: createTimelineItem("assistant_message", "1"),
itemKind: "assistant_message",
});
await expect(
database.db.insert(agentTimelineRows).values({
agentId: "agent-1",
seq: 1,
committedAt: createTimestamp(2),
item: createTimelineItem("assistant_message", "duplicate"),
itemKind: "assistant_message",
}),
).rejects.toThrow();
await runPaseoDbMigrations(database.db);
const migrationRows = await database.db.execute(
sql`select * from drizzle.__drizzle_migrations order by created_at`,
);
expect(migrationRows.rows).toHaveLength(1);
await database.close();
});
});
@@ -0,0 +1,24 @@
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { runPaseoDbMigrations } from "./migrations.js";
import { paseoDbSchema } from "./schema.js";
export interface PaseoDatabaseHandle {
client: PGlite;
db: ReturnType<typeof drizzle<typeof paseoDbSchema>>;
close(): Promise<void>;
}
export async function openPaseoDatabase(dataDir: string): Promise<PaseoDatabaseHandle> {
const client = new PGlite(dataDir);
const db = drizzle(client, { schema: paseoDbSchema });
await runPaseoDbMigrations(db);
return {
client,
db,
async close(): Promise<void> {
await client.close();
},
};
}
+78
View File
@@ -0,0 +1,78 @@
import { boolean, index, integer, jsonb, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import type { AgentPersistenceHandle, AgentRuntimeInfo, AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
export const projects = pgTable("projects", {
projectId: text("project_id").primaryKey(),
rootPath: text("root_path").notNull(),
kind: text("kind").notNull(),
displayName: text("display_name").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
archivedAt: text("archived_at"),
});
export const workspaces = pgTable(
"workspaces",
{
workspaceId: text("workspace_id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.projectId, { onDelete: "restrict", onUpdate: "cascade" }),
cwd: text("cwd").notNull(),
kind: text("kind").notNull(),
displayName: text("display_name").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
archivedAt: text("archived_at"),
},
(table) => [index("workspaces_project_id_idx").on(table.projectId)],
);
export const agentSnapshots = pgTable("agent_snapshots", {
agentId: text("agent_id").primaryKey(),
provider: text("provider").notNull(),
workspaceId: text("workspace_id").references(() => workspaces.workspaceId, {
onDelete: "set null",
onUpdate: "cascade",
}),
cwd: text("cwd").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastActivityAt: text("last_activity_at"),
lastUserMessageAt: text("last_user_message_at"),
title: text("title"),
labels: jsonb("labels").$type<StoredAgentRecord["labels"]>().notNull(),
lastStatus: text("last_status").notNull(),
lastModeId: text("last_mode_id"),
config: jsonb("config").$type<StoredAgentRecord["config"]>(),
runtimeInfo: jsonb("runtime_info").$type<AgentRuntimeInfo>(),
persistence: jsonb("persistence").$type<AgentPersistenceHandle>(),
requiresAttention: boolean("requires_attention").notNull(),
attentionReason: text("attention_reason"),
attentionTimestamp: text("attention_timestamp"),
internal: boolean("internal").notNull(),
archivedAt: text("archived_at"),
});
export const agentTimelineRows = pgTable(
"agent_timeline_rows",
{
agentId: text("agent_id").notNull(),
seq: integer("seq").notNull(),
committedAt: text("committed_at").notNull(),
item: jsonb("item").$type<AgentTimelineItem>().notNull(),
itemKind: text("item_kind"),
},
(table) => [
primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" }),
],
);
export const paseoDbSchema = {
projects,
workspaces,
agentSnapshots,
agentTimelineRows,
};
@@ -1,83 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import type { ManagedAgent } from "./agent/agent-manager.js";
import { describe, expect, test } from "vitest";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import {
attachAgentStoragePersistence,
buildConfigOverrides,
buildSessionConfig,
} from "./persistence-hooks.js";
import type {
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
} from "./agent/agent-sdk-types.js";
const testLogger = {
child: () => testLogger,
error: vi.fn(),
} as any;
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
activeForegroundTurnId?: string | null;
};
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
const provider = overrides.provider ?? "claude";
const cwd = overrides.cwd ?? "/tmp/project";
const lifecycle = overrides.lifecycle ?? "idle";
const configOverrides = overrides.config ?? {};
const config: AgentSessionConfig = {
provider,
cwd,
modeId: configOverrides.modeId ?? "plan",
model: configOverrides.model ?? "claude-3.5-sonnet",
extra: configOverrides.extra ?? { claude: { tone: "focused" } },
};
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
const agent: ManagedAgent = {
id: overrides.id ?? "agent-1",
provider,
cwd,
session,
capabilities: overrides.capabilities ?? {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
config,
lifecycle,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
availableModes: overrides.availableModes ?? [],
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
activeForegroundTurnId,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: overrides.timeline ?? [],
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
lastUsage: overrides.lastUsage,
lastError: overrides.lastError,
};
return agent;
}
import { buildConfigOverrides, buildSessionConfig } from "./persistence-hooks.js";
function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
const now = new Date().toISOString();
@@ -100,43 +23,6 @@ function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord
}
describe("persistence hooks", () => {
test("attachAgentStoragePersistence forwards agent snapshots", async () => {
const applySnapshot = vi.fn().mockResolvedValue(undefined);
let subscriber: (event: any) => void = () => {
throw new Error("Agent manager subscriber was not registered");
};
const agentManager = {
subscribe: vi.fn((callback: (event: any) => void) => {
subscriber = callback;
return () => {
subscriber = () => {
throw new Error("Agent manager subscriber was not registered");
};
};
}),
};
attachAgentStoragePersistence(
testLogger,
agentManager as any,
{
applySnapshot,
list: vi.fn(),
} as any,
);
expect(agentManager.subscribe).toHaveBeenCalledTimes(1);
const agent = createManagedAgent();
subscriber({ type: "agent_state", agent });
expect(applySnapshot).toHaveBeenCalledWith(agent);
subscriber({
type: "agent_stream",
agentId: agent.id,
event: { type: "timeline", item: { type: "assistant_message", text: "hi" } },
});
expect(applySnapshot).toHaveBeenCalledTimes(1);
});
test("buildConfigOverrides carries systemPrompt and mcpServers", () => {
const record = createRecord({
title: "Voice agent (current)",
+28 -36
View File
@@ -1,45 +1,13 @@
import type { AgentManager } from "./agent/agent-manager.js";
import type pino from "pino";
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike;
error(...args: any[]): void;
};
function getLogger(logger: LoggerLike): LoggerLike {
return logger.child({ module: "persistence" });
}
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider } from "./agent/provider-manifest.js";
function isKnownProvider(provider: string): provider is AgentProvider {
return provider === "claude" || provider === "codex" || provider === "opencode";
}
/**
* Attach AgentStorage persistence to an AgentManager instance so every
* agent_state snapshot is flushed to disk.
*/
export function attachAgentStoragePersistence(
logger: LoggerLike,
agentManager: AgentManagerStateSource,
storage: AgentStoragePersistence,
): () => void {
const log = getLogger(logger);
const unsubscribe = agentManager.subscribe((event) => {
if (event.type !== "agent_state") {
return;
}
void storage.applySnapshot(event.agent).catch((error) => {
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
});
});
return unsubscribe;
}
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
return {
cwd: record.cwd,
@@ -71,6 +39,30 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
};
}
export function toAgentPersistenceHandle(
logger: pino.Logger,
handle: StoredAgentRecord["persistence"],
) {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isValidAgentProvider(provider)) {
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
return null;
}
if (!handle.sessionId) {
logger.warn("Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
};
}
export function extractTimestamps(record: StoredAgentRecord): {
createdAt: Date;
updatedAt: Date;
@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
describe("agent loading boundary", () => {
test("session runtime code does not directly hydrate provider history", () => {
const sessionSource = readFileSync(new URL("./session.ts", import.meta.url), "utf8");
const agentLoadingSource = readFileSync(
new URL("./agent-loading-service.ts", import.meta.url),
"utf8",
);
expect(sessionSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
expect(agentLoadingSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
});
});
@@ -0,0 +1,384 @@
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, test, vi } from "vitest";
import { AgentLoadingService } from "./agent-loading-service.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { openPaseoDatabase } from "./db/pglite-database.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
function createStoredAgentRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
const now = "2026-03-25T00:00:00.000Z";
return {
id: "agent-compat-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: now,
updatedAt: now,
title: null,
labels: {},
lastStatus: "idle",
config: {
model: "gpt-5.1-codex-mini",
},
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-compat-1",
provider: "codex",
cwd: "/tmp/project",
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
describe("AgentLoadingService", () => {
test("ensureAgentLoaded seeds the live timeline from durable rows for an unloaded persisted agent", async () => {
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-load-"));
const logger = pino({ level: "silent" });
const database = await openPaseoDatabase(path.join(workspaceRoot, "db"));
try {
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
durableTimelineStore: new DbAgentTimelineStore(database.db),
logger,
idFactory: () => "00000000-0000-4000-8000-000000000301",
});
const service = new AgentLoadingService({
agentManager: manager as any,
agentStorage: storage as any,
logger,
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workspaceRoot,
model: "gpt-5.1-codex-mini",
});
await manager.runAgent(snapshot.id, "say 'timeline test'");
await manager.flush();
await storage.flush();
rmSync(
path.join(
os.tmpdir(),
"paseo-fake-provider-history",
"codex",
`${snapshot.persistence?.sessionId}.jsonl`,
),
{ force: true },
);
await manager.closeAgent(snapshot.id);
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([{ type: "assistant_message", text: "timeline test" }]);
} finally {
await database.close();
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test("ensureAgentLoaded succeeds when provider history is absent", async () => {
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-"));
const logger = pino({ level: "silent" });
try {
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000302",
});
const service = new AgentLoadingService({
agentManager: manager as any,
agentStorage: storage as any,
logger,
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workspaceRoot,
model: "gpt-5.1-codex-mini",
});
await manager.flush();
await storage.flush();
await manager.closeAgent(snapshot.id);
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
} finally {
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test("ensureAgentLoaded dedupes concurrent cold-load bootstrap", async () => {
const deferred = createDeferred<any>();
let currentAgent: any = null;
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-dedupe" });
const agentStorage = {
get: vi.fn(async () =>
createStoredAgentRecord({
id: "agent-compat-dedupe",
cwd: "/tmp/dedupe",
persistence: {
provider: "codex",
sessionId: "provider-session-dedupe",
},
}),
),
};
const agentManager = {
getAgent: vi.fn(() => currentAgent),
resumeAgentFromPersistence: vi.fn(async () => deferred.promise),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: agentStorage as any,
logger: logger as any,
});
const firstLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
const secondLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
deferred.resolve(snapshot);
const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]);
expect(firstResult).toEqual(snapshot);
expect(secondResult).toEqual(snapshot);
expect(agentStorage.get).toHaveBeenCalledTimes(1);
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledTimes(1);
});
test("resumeAgent delegates to manager resume", async () => {
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-resume" });
const agentManager = {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(async () => snapshot),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.resumeAgent({
handle: {
provider: "codex",
sessionId: "provider-session-resume",
},
overrides: {
model: "gpt-5.4",
},
});
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "provider-session-resume",
},
{
model: "gpt-5.4",
},
);
expect(result).toEqual(snapshot);
});
test("refreshAgent reloads loaded persisted agents", async () => {
const existing = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
const reloaded = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
let currentAgent: any = existing;
const agentManager = {
getAgent: vi.fn(() => currentAgent),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(async () => {
currentAgent = reloaded;
return reloaded;
}),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-loaded" });
expect(agentManager.reloadAgentSession).toHaveBeenCalledWith("agent-compat-refresh-loaded");
expect(result).toEqual(reloaded);
});
test("refreshAgent keeps loaded non-persisted agents without reloading", async () => {
const existing = createCompatibilitySnapshot({
id: "agent-compat-refresh-live",
persistence: null,
});
const agentManager = {
getAgent: vi.fn(() => existing),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-live" });
expect(agentManager.reloadAgentSession).not.toHaveBeenCalled();
expect(result).toEqual(existing);
});
test("refreshAgent resumes unloaded persisted agents", async () => {
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-refresh-cold" });
const record = createStoredAgentRecord({
id: "agent-compat-refresh-cold",
cwd: "/tmp/refresh-cold",
persistence: {
provider: "codex",
sessionId: "provider-session-refresh-cold",
},
});
const agentManager = {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(async () => snapshot),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: vi.fn(async () => record),
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-cold" });
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "provider-session-refresh-cold",
nativeHandle: undefined,
metadata: undefined,
},
{
cwd: "/tmp/refresh-cold",
modeId: undefined,
model: "gpt-5.1-codex-mini",
thinkingOptionId: undefined,
title: undefined,
extra: undefined,
systemPrompt: undefined,
mcpServers: undefined,
},
"agent-compat-refresh-cold",
{
createdAt: new Date("2026-03-25T00:00:00.000Z"),
updatedAt: new Date("2026-03-25T00:00:00.000Z"),
lastUserMessageAt: null,
labels: {},
},
);
expect(result).toEqual(snapshot);
});
test("refreshAgent preserves the unloaded no-persistence error", async () => {
const service = new AgentLoadingService({
agentManager: {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
} as any,
agentStorage: {
get: async () =>
createStoredAgentRecord({
id: "agent-compat-no-persistence",
persistence: null,
}),
} as any,
logger: {
child: () => ({
child: () => null,
info: vi.fn(),
warn: vi.fn(),
}),
info: vi.fn(),
warn: vi.fn(),
} as any,
});
await expect(
service.refreshAgent({ agentId: "agent-compat-no-persistence" }),
).rejects.toThrow("Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence");
});
});
@@ -0,0 +1,330 @@
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
function createStoredAgentRecord(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: "2026-03-24T00:00:00.000Z",
title: null,
labels: {},
lastStatus: "idle",
config: null,
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
archivedAt: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
...overrides,
};
}
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
function createSessionForOwnershipTests(options?: {
agentLoadingService?: {
ensureAgentLoaded?: (options: { agentId: string }) => Promise<any>;
resumeAgent?: (options: {
handle: { provider: string; sessionId: string };
overrides?: Record<string, unknown>;
}) => Promise<any>;
refreshAgent?: (options: { agentId: string }) => Promise<any>;
};
storedRecord?: Record<string, unknown> | null;
loadedAgent?: Record<string, unknown> | null;
timelineRows?: Array<{ seq: number; item: Record<string, unknown>; timestamp: Date }>;
}) {
const emitted: Array<{ type: string; payload: unknown }> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const agentManager = {
subscribe: () => () => {},
listAgents: () => [],
getAgent: vi.fn(() => options?.loadedAgent ?? null),
createAgent: vi.fn(async () => {
throw new Error("Session should delegate unloaded bootstrap to AgentLoadingService");
}),
resumeAgentFromPersistence: vi.fn(async () => {
throw new Error("Session should delegate persistence resume to AgentLoadingService");
}),
reloadAgentSession: vi.fn(async () => {
throw new Error("Session should delegate refresh reload to AgentLoadingService");
}),
hydrateTimelineFromProvider: vi.fn(async () => {
throw new Error("Session should not call hydrateTimelineFromProvider directly");
}),
fetchTimeline: vi.fn(async () => ({
rows: options?.timelineRows ?? [],
hasOlder: false,
hasNewer: false,
})),
recordUserMessage: vi.fn(),
waitForAgentRunStart: vi.fn(async () => undefined),
getTimeline: vi.fn(() => []),
};
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: agentManager as any,
agentStorage: {
list: async () => (options?.storedRecord ? [options.storedRecord as any] : []),
get: async () => (options?.storedRecord as any) ?? null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
agentLoadingService: options?.agentLoadingService,
} as any) as any;
return { session, emitted, agentManager };
}
describe("provider history compatibility ownership", () => {
test("fetch_agent_timeline_request delegates unloaded bootstrap through the compatibility seam", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
timelineRows: [
{
seq: 1,
item: { type: "assistant_message", text: "rehydrated from provider history" },
timestamp: new Date("2026-03-24T00:00:01.000Z"),
},
],
agentLoadingService: {
ensureAgentLoaded,
},
});
session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "fetch_agent_timeline_request",
requestId: "req-fetch",
agentId: "agent-1",
});
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "fetch_agent_timeline_response",
payload: expect.objectContaining({
requestId: "req-fetch",
agentId: "agent-1",
error: null,
entries: [
expect.objectContaining({
seq: 1,
}),
],
}),
});
});
test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, agentManager, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
agentLoadingService: {
ensureAgentLoaded,
},
});
session.resolveAgentIdentifier = vi.fn(async () => ({ ok: true, agentId: "agent-1" }));
session.unarchiveAgentState = vi.fn(async () => true);
session.buildAgentPrompt = vi.fn((text: string) => text);
session.startAgentStream = vi.fn(() => ({ ok: true }));
await session.handleMessage({
type: "send_agent_message_request",
requestId: "req-send",
agentId: "agent-1",
text: "hello",
images: [],
messageId: "msg-1",
});
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(ensureAgentLoaded.mock.invocationCallOrder[0]).toBeLessThan(
agentManager.recordUserMessage.mock.invocationCallOrder[0],
);
expect(agentManager.recordUserMessage).toHaveBeenCalledWith("agent-1", "hello", {
messageId: "msg-1",
emitState: false,
});
expect(session.startAgentStream).toHaveBeenCalledWith("agent-1", "hello");
expect(emitted).toContainEqual({
type: "send_agent_message_response",
payload: {
requestId: "req-send",
agentId: "agent-1",
accepted: true,
error: null,
},
});
});
test("resume_agent_request delegates persistence bootstrap through the compatibility seam", async () => {
const resumeAgent = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
agentLoadingService: {
resumeAgent,
},
});
session.unarchiveAgentByHandle = vi.fn(async () => undefined);
session.unarchiveAgentState = vi.fn(async () => true);
session.forwardAgentUpdate = vi.fn(async () => undefined);
session.getAgentPayloadById = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "resume_agent_request",
requestId: "req-resume",
handle: {
provider: "codex",
sessionId: "provider-session-1",
},
overrides: {
model: "gpt-5.4",
},
});
expect(resumeAgent).toHaveBeenCalledWith({
handle: {
provider: "codex",
sessionId: "provider-session-1",
},
overrides: {
model: "gpt-5.4",
},
});
expect(emitted).toContainEqual({
type: "status",
payload: expect.objectContaining({
status: "agent_resumed",
requestId: "req-resume",
agentId: "agent-1",
}),
});
});
test("refresh_agent_request delegates loaded persisted refresh through the compatibility seam", async () => {
const refreshAgent = vi.fn(async () =>
createCompatibilitySnapshot({
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
}),
);
const { session, emitted } = createSessionForOwnershipTests({
loadedAgent: createCompatibilitySnapshot(),
agentLoadingService: {
refreshAgent,
},
});
session.unarchiveAgentState = vi.fn(async () => true);
session.interruptAgentIfRunning = vi.fn(async () => undefined);
session.forwardAgentUpdate = vi.fn(async () => undefined);
await session.handleMessage({
type: "refresh_agent_request",
requestId: "req-refresh-loaded",
agentId: "agent-1",
});
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "req-refresh-loaded",
agentId: "agent-1",
timelineSize: 0,
},
});
});
test("refresh_agent_request delegates unloaded persisted refresh through the compatibility seam", async () => {
const refreshAgent = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
agentLoadingService: {
refreshAgent,
},
});
session.unarchiveAgentState = vi.fn(async () => true);
session.interruptAgentIfRunning = vi.fn(async () => undefined);
session.forwardAgentUpdate = vi.fn(async () => undefined);
await session.handleMessage({
type: "refresh_agent_request",
requestId: "req-refresh-unloaded",
agentId: "agent-1",
});
expect(session.interruptAgentIfRunning).not.toHaveBeenCalled();
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "req-refresh-unloaded",
agentId: "agent-1",
timelineSize: 0,
},
});
});
});
+84 -507
View File
@@ -54,9 +54,7 @@ import {
type VoiceTurnController,
} from "./voice/voice-turn-controller.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
import { experimental_createMCPClient } from "ai";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
@@ -78,11 +76,6 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import {
projectTimelineRows,
selectTimelineWindowByProjectedLimit,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import {
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
StructuredAgentFallbackError,
@@ -100,15 +93,12 @@ import type {
AgentProvider,
AgentPersistenceHandle,
} from "./agent/agent-sdk-types.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import {
buildProjectPlacementForCwd,
detectStaleWorkspaces,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
} from "./workspace-registry-model.js";
import type {
@@ -117,10 +107,8 @@ import type {
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import { AgentLoadingService } from "./agent-loading-service.js";
import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
@@ -184,7 +172,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
};
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
@@ -194,6 +181,16 @@ const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
const MAX_TERMINAL_STREAM_SLOTS = 256;
type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & {
beginDelete(agentId: string): void;
};
function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void {
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
(agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId);
}
}
function deriveInitialAgentTitle(prompt: string): string | null {
const firstContentLine = prompt
.split(/\r?\n/)
@@ -402,9 +399,11 @@ export type SessionOptions = {
pushTokenStore: PushTokenStore;
paseoHome: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
workspaceReconciliationService?: WorkspaceReconciliationService;
agentLoadingService?: AgentLoadingService;
createAgentMcpTransport: AgentMcpTransportFactory;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
@@ -513,30 +512,6 @@ function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: strin
return DEFAULT_AGENT_PROVIDER;
}
function toAgentPersistenceHandle(
logger: pino.Logger,
handle: StoredAgentRecord["persistence"],
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isValidAgentProvider(provider)) {
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
return null;
}
if (!handle.sessionId) {
logger.warn("Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
} satisfies AgentPersistenceHandle;
}
/**
* Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing.
@@ -585,9 +560,11 @@ export class Session {
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
private agentTools: ToolSet | null = null;
private agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly agentStorage: AgentSnapshotStore;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly workspaceReconciliationService: WorkspaceReconciliationService;
private readonly agentLoadingService: AgentLoadingService;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
@@ -648,6 +625,8 @@ export class Session {
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceReconciliationService,
agentLoadingService,
createAgentMcpTransport,
stt,
tts,
@@ -666,10 +645,33 @@ export class Session {
this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome;
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
sessionId: this.sessionId,
});
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry;
this.workspaceRegistry = workspaceRegistry;
this.workspaceReconciliationService =
workspaceReconciliationService ??
new WorkspaceReconciliationService({
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
agentStorage: this.agentStorage,
buildProjectPlacement: async (cwd) => this.buildProjectPlacement(cwd),
syncWorkspaceGitWatchTarget: async (cwd, options) =>
this.syncWorkspaceGitWatchTarget(cwd, options),
removeWorkspaceGitWatchTarget: async (cwd) => this.removeWorkspaceGitWatchTarget(cwd),
});
this.agentLoadingService =
agentLoadingService ??
new AgentLoadingService({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
if (this.terminalManager) {
@@ -697,11 +699,6 @@ export class Session {
this.getSpeechReadiness = dictation?.getSpeechReadiness;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.abortController = new AbortController();
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
sessionId: this.sessionId,
});
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
runtimeSettings: this.agentProviderRuntimeSettings,
});
@@ -992,7 +989,6 @@ export class Session {
event: serializedEvent,
timestamp: new Date().toISOString(),
...(typeof event.seq === "number" ? { seq: event.seq } : {}),
...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}),
} as const;
this.emit({
@@ -1096,58 +1092,7 @@ export class Session {
}
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
return inflight;
}
const initPromise = (async () => {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent resumed from persistence",
);
} else {
const config = buildSessionConfig(record);
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent created from stored config",
);
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
return this.agentManager.getAgent(agentId) ?? snapshot;
})();
pendingAgentInitializations.set(agentId, initPromise);
try {
return await initPromise;
} finally {
const current = pendingAgentInitializations.get(agentId);
if (current === initPromise) {
pendingAgentInitializations.delete(agentId);
}
}
return this.agentLoadingService.ensureAgentLoaded({ agentId });
}
private matchesAgentFilter(options: {
@@ -1266,155 +1211,15 @@ export class Session {
});
}
private buildPersistedProjectRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: input.placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
kind: deriveProjectKind(input.placement.checkout),
displayName: input.placement.projectName,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private buildPersistedWorkspaceRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: input.workspaceId,
projectId: input.placement.projectKey,
cwd: input.workspaceId,
kind: deriveWorkspaceKind(input.placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(projectId, archivedAt);
}
}
private async reconcileWorkspaceRecord(workspaceId: string): Promise<{
workspace: PersistedWorkspaceRecord;
changed: boolean;
}> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId);
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
isGit: placement.checkout.isGit,
});
const now = new Date().toISOString();
const nextProjectCreatedAt = existing?.createdAt ?? now;
const nextWorkspaceCreatedAt = existing?.createdAt ?? now;
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: nextWorkspaceCreatedAt,
updatedAt: now,
});
const needsWorkspaceUpdate =
!existing ||
existing.archivedAt ||
existing.projectId !== nextWorkspaceRecord.projectId ||
existing.kind !== nextWorkspaceRecord.kind ||
existing.displayName !== nextWorkspaceRecord.displayName;
const needsProjectUpdate =
!currentProjectRecord ||
currentProjectRecord.archivedAt ||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
currentProjectRecord.kind !== nextProjectRecord.kind ||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
return {
workspace: existing!,
changed: false,
};
}
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) {
await this.archiveProjectRecordIfEmpty(existing.projectId, now);
}
return {
workspace: nextWorkspaceRecord,
changed: true,
};
return this.workspaceReconciliationService.reconcileWorkspaceRecord(workspaceId);
}
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
const changedWorkspaceIds = new Set<string>();
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces,
agentRecords: (await this.agentStorage.list()).map((agent) => ({
cwd: agent.cwd,
archivedAt: agent.archivedAt ?? null,
})),
checkDirectoryExists: async (cwd) => {
try {
await stat(cwd);
return true;
} catch {
return false;
}
},
});
for (const workspaceId of staleWorkspaceIds) {
await this.archiveWorkspaceRecord(workspaceId);
changedWorkspaceIds.add(workspaceId);
}
for (const workspace of activeWorkspaces) {
if (staleWorkspaceIds.has(workspace.workspaceId)) {
continue;
}
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
if (result.changed) {
changedWorkspaceIds.add(result.workspace.workspaceId);
}
}
return changedWorkspaceIds;
return this.workspaceReconciliationService.reconcileActiveWorkspaceRecords();
}
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
@@ -1880,8 +1685,8 @@ export class Session {
(await this.agentStorage.get(agentId))?.cwd ??
null;
// Prevent the persistence hook from re-creating the record while we close/delete.
this.agentStorage.beginDelete(agentId);
// File-backed storage still needs an early delete fence before closeAgent().
beginAgentDeleteIfSupported(this.agentStorage, agentId);
try {
await this.agentManager.closeAgent(agentId);
@@ -1894,10 +1699,11 @@ export class Session {
try {
await this.agentStorage.remove(agentId);
await this.agentManager.deleteCommittedTimeline(agentId);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to remove agent ${agentId} from registry`,
`Failed to fully delete agent ${agentId}`,
);
}
@@ -1946,37 +1752,7 @@ export class Session {
}
const archivedAt = new Date().toISOString();
const existing = await this.agentStorage.get(agentId);
let archivedRecord: StoredAgentRecord | null = existing;
if (!archivedRecord) {
const liveAgent = this.agentManager.getAgent(agentId);
if (!liveAgent) {
throw new Error(`Agent not found: ${agentId}`);
}
await this.agentStorage.applySnapshot(liveAgent, {
internal: liveAgent.internal,
});
archivedRecord = await this.agentStorage.get(agentId);
if (!archivedRecord) {
throw new Error(`Agent not found in storage after snapshot: ${agentId}`);
}
}
const normalizedStatus =
archivedRecord.lastStatus === "running" || archivedRecord.lastStatus === "initializing"
? "idle"
: archivedRecord.lastStatus;
const nextRecord: StoredAgentRecord = {
...archivedRecord,
archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
await this.agentStorage.upsert(nextRecord);
const nextRecord = await this.agentManager.archiveSnapshot(agentId, archivedAt);
// Unload the agent from memory — the storage record is the source of truth now.
// This tears down the provider session and drops the hydrated timeline,
@@ -1993,29 +1769,11 @@ export class Session {
}
private async unarchiveAgentState(agentId: string): Promise<boolean> {
const record = await this.agentStorage.get(agentId);
if (!record || !record.archivedAt) {
return false;
}
await this.agentStorage.upsert({
...record,
archivedAt: null,
});
this.agentManager.notifyAgentState(agentId);
return true;
return this.agentManager.unarchiveSnapshot(agentId);
}
private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise<void> {
const records = await this.agentStorage.list();
const matched = records.find(
(record) =>
record.persistence?.provider === handle.provider &&
record.persistence?.sessionId === handle.sessionId,
);
if (!matched) {
return;
}
await this.unarchiveAgentState(matched.id);
await this.agentManager.unarchiveSnapshotByHandle(handle);
}
private async handleUpdateAgentRequest(
@@ -2051,26 +1809,10 @@ export class Session {
}
try {
const liveAgent = this.agentManager.getAgent(agentId);
if (liveAgent) {
if (normalizedName) {
await this.agentManager.setTitle(agentId, normalizedName);
}
if (normalizedLabels) {
await this.agentManager.setLabels(agentId, normalizedLabels);
}
} else {
const existing = await this.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
await this.agentStorage.upsert({
...existing,
...(normalizedName ? { title: normalizedName } : {}),
...(normalizedLabels ? { labels: { ...existing.labels, ...normalizedLabels } } : {}),
});
}
await this.agentManager.updateAgentMetadata(agentId, {
...(normalizedName ? { title: normalizedName } : {}),
...(normalizedLabels ? { labels: normalizedLabels } : {}),
});
this.emit({
type: "update_agent_response",
@@ -2750,9 +2492,11 @@ export class Session {
);
try {
await this.unarchiveAgentByHandle(handle);
const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides);
const snapshot = await this.agentLoadingService.resumeAgent({
handle,
overrides,
});
await this.unarchiveAgentState(snapshot.id);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
if (requestId) {
@@ -2793,32 +2537,10 @@ export class Session {
try {
await this.unarchiveAgentState(agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
if (this.agentManager.getAgent(agentId)) {
await this.interruptAgentIfRunning(agentId);
if (existing.persistence) {
snapshot = await this.agentManager.reloadAgentSession(agentId);
} else {
snapshot = existing;
}
} else {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence);
if (!handle) {
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
}
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
const snapshot = await this.agentLoadingService.refreshAgent({ agentId });
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(agentId).length;
if (requestId) {
@@ -5907,69 +5629,11 @@ export class Session {
worktreePath: string;
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const now = new Date().toISOString();
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
const existingProject = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
await this.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
return this.workspaceReconciliationService.registerPendingWorktreeWorkspace(options);
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existing = await this.workspaceRegistry.get(workspaceId);
if (!existing || existing.archivedAt) {
this.removeWorkspaceGitWatchTarget(workspaceId);
return;
}
const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
this.removeWorkspaceGitWatchTarget(workspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existing.projectId, nextArchivedAt);
}
await this.workspaceReconciliationService.archiveWorkspaceRecord(workspaceId, archivedAt);
}
private async emitWorkspaceUpdateForCwd(
@@ -6409,17 +6073,10 @@ export class Session {
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = msg.projection ?? "projected";
const requestedLimit = msg.limit;
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined);
const shouldLimitByProjectedWindow =
projection === "canonical" &&
direction === "tail" &&
typeof requestedLimit === "number" &&
requestedLimit > 0;
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
seq: msg.cursor.seq,
}
: undefined;
@@ -6427,87 +6084,19 @@ export class Session {
try {
const snapshot = await this.ensureAgentLoaded(msg.agentId);
const agentPayload = await this.buildAgentPayload(snapshot);
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
const timeline = await this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit:
shouldLimitByProjectedWindow && typeof requestedLimit === "number"
? Math.max(1, Math.floor(requestedLimit))
: limit,
limit,
});
let hasOlder = timeline.hasOlder;
let hasNewer = timeline.hasNewer;
let startCursor: { epoch: string; seq: number } | null = null;
let endCursor: { epoch: string; seq: number } | null = null;
let entries: ReturnType<typeof projectTimelineRows>;
if (shouldLimitByProjectedWindow) {
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
let fetchLimit = projectedLimit;
let projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
provider: snapshot.provider,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
while (timeline.hasOlder) {
const needsMoreProjectedEntries =
projectedWindow.projectedEntries.length < projectedLimit;
const firstLoadedRow = timeline.rows[0];
const firstSelectedRow = projectedWindow.selectedRows[0];
const startsAtLoadedBoundary =
firstLoadedRow != null &&
firstSelectedRow != null &&
firstSelectedRow.seq === firstLoadedRow.seq;
const boundaryIsAssistantChunk =
startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message";
if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) {
break;
}
const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1);
const nextFetchLimit = Math.min(maxRows, fetchLimit * 2);
if (nextFetchLimit <= fetchLimit) {
break;
}
fetchLimit = nextFetchLimit;
timeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit: fetchLimit,
});
projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
provider: snapshot.provider,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
}
const selectedRows = projectedWindow.selectedRows;
entries = projectTimelineRows(selectedRows, snapshot.provider, projection);
if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) {
startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq };
endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq };
hasOlder = projectedWindow.minSeq > timeline.window.minSeq;
hasNewer = false;
}
} else {
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
entries = projectTimelineRows(timeline.rows, snapshot.provider, projection);
}
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
const entries = timeline.rows.map((row) => ({
provider: snapshot.provider,
item: row.item,
timestamp: row.timestamp,
seq: row.seq,
}));
this.emit({
type: "fetch_agent_timeline_response",
@@ -6516,16 +6105,10 @@ export class Session {
agentId: msg.agentId,
agent: agentPayload,
direction,
projection,
epoch: timeline.epoch,
reset: timeline.reset,
staleCursor: timeline.staleCursor,
gap: timeline.gap,
window: timeline.window,
startCursor,
endCursor,
hasOlder,
hasNewer,
startSeq: firstRow?.seq ?? null,
endSeq: lastRow?.seq ?? null,
hasOlder: timeline.hasOlder,
hasNewer: timeline.hasNewer,
entries,
error: null,
},
@@ -6542,14 +6125,8 @@ export class Session {
agentId: msg.agentId,
agent: null,
direction,
projection,
epoch: "",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
startCursor: null,
endCursor: null,
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
entries: [],
@@ -170,7 +170,8 @@ describe("workspace git watch targets", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
sessionAny.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () =>
new Set();
let descriptor = {
id: "/tmp/repo",
@@ -0,0 +1,201 @@
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
function createSessionForOwnershipTests(options?: { workspaceReconciliationService?: any }) {
const emitted: Array<{ type: string; payload: unknown }> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async (projectId: string) =>
createPersistedProjectRecord({
projectId,
rootPath: "/tmp/repo",
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
upsert: async () => {
throw new Error("not used");
},
archive: async () => {
throw new Error("not used");
},
remove: async () => {
throw new Error("not used");
},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {
throw new Error("not used");
},
archive: async () => {
throw new Error("not used");
},
remove: async () => {
throw new Error("not used");
},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
workspaceReconciliationService: options?.workspaceReconciliationService,
}) as any;
return { session, emitted };
}
describe("workspace reconciliation ownership", () => {
test("open_project_request delegates registration to WorkspaceReconciliationService", async () => {
const reconcileWorkspaceRecord = vi.fn(async () => ({
workspace: createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "/tmp/repo",
cwd: "/tmp/repo",
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
changed: true,
}));
const { session, emitted } = createSessionForOwnershipTests({
workspaceReconciliationService: {
reconcileWorkspaceRecord,
reconcileActiveWorkspaceRecords: async () => new Set<string>(),
registerPendingWorktreeWorkspace: async () => {
throw new Error("not used");
},
archiveWorkspaceRecord: async () => {},
},
});
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "repo",
checkout: {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
session.emitWorkspaceUpdateForCwd = vi.fn(async () => {});
await session.handleMessage({
type: "open_project_request",
cwd: "/tmp/repo",
requestId: "req-open",
});
expect(reconcileWorkspaceRecord).toHaveBeenCalledWith("/tmp/repo");
expect(emitted).toContainEqual({
type: "open_project_response",
payload: expect.objectContaining({
requestId: "req-open",
error: null,
workspace: expect.objectContaining({
id: "/tmp/repo",
}),
}),
});
});
test("workspace update fanout delegates sweep to WorkspaceReconciliationService", async () => {
const reconcileActiveWorkspaceRecords = vi.fn(
async () => new Set(["/tmp/repo", "/tmp/repo/worktree"]),
);
const { session, emitted } = createSessionForOwnershipTests({
workspaceReconciliationService: {
reconcileWorkspaceRecord: async () => {
throw new Error("not used");
},
reconcileActiveWorkspaceRecords,
registerPendingWorktreeWorkspace: async () => {
throw new Error("not used");
},
archiveWorkspaceRecord: async () => {},
},
});
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-ownership",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.listWorkspaceDescriptorsSnapshot = async () => [
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "directory",
name: "repo",
status: "done",
activityAt: null,
},
{
id: "/tmp/repo/worktree",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "feature-a",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
},
];
await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree");
expect(reconcileActiveWorkspaceRecords).toHaveBeenCalledTimes(1);
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update") as
| Array<{ type: "workspace_update"; payload: any }>;
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
"/tmp/repo",
"/tmp/repo/worktree",
]);
});
});
@@ -292,7 +292,7 @@ describe("workspace aggregation", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () => new Set();
session.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () => new Set();
session.listWorkspaceDescriptorsSnapshot = async () => [
{
@@ -411,7 +411,7 @@ describe("workspace aggregation", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () =>
session.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () =>
new Set(["/tmp/repo", "/tmp/repo/worktree"]);
session.listWorkspaceDescriptorsSnapshot = async () => [
{
@@ -0,0 +1,156 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
describe("snapshot mutation ownership boundary", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("daemon live mutations write one durable snapshot through the manager-owned path", async () => {
const daemonHandle = await createTestPaseoDaemon();
const cwd = mkdtempSync(path.join(os.tmpdir(), "snapshot-owner-live-"));
try {
const snapshot = await daemonHandle.daemon.agentManager.createAgent({
provider: "codex",
cwd,
model: "gpt-5.2-codex",
});
await daemonHandle.daemon.agentManager.flush();
const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4");
await daemonHandle.daemon.agentManager.flush();
expect(applySnapshotSpy).toHaveBeenCalledTimes(1);
const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id);
expect(persisted?.config?.model).toBe("gpt-5.4");
} finally {
rmSync(cwd, { recursive: true, force: true });
await daemonHandle.close();
}
});
test("session runtime flows delegate snapshot mutations to agent manager without direct storage writes", async () => {
const onMessage = vi.fn();
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => ({
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: archivedAt,
title: null,
labels: {},
lastStatus: "idle" as const,
config: null,
persistence: null,
archivedAt,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
}));
const unarchiveSnapshot = vi.fn(async () => true);
const unarchiveSnapshotByHandle = vi.fn(async () => undefined);
const updateAgentMetadata = vi.fn(async () => undefined);
const directStorageWrite = vi.fn(async () => {
throw new Error("Session should not write snapshots directly");
});
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = new Session({
clientId: "test-client",
onMessage,
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveSnapshot,
unarchiveSnapshot,
unarchiveSnapshotByHandle,
updateAgentMetadata,
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
applySnapshot: directStorageWrite,
upsert: directStorageWrite,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
}) as any;
const archiveResult = await session.archiveAgentState("agent-1");
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
expect(archiveResult.archivedAt).toBeTruthy();
await session.unarchiveAgentState("agent-1");
expect(unarchiveSnapshot).toHaveBeenCalledWith("agent-1");
const handle = { provider: "codex", sessionId: "session-1" };
await session.unarchiveAgentByHandle(handle);
expect(unarchiveSnapshotByHandle).toHaveBeenCalledWith(handle);
await session.handleUpdateAgentRequest(
"agent-1",
"Renamed agent",
{ lane: "phase-1a" },
"req-1",
);
expect(updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Renamed agent",
labels: { lane: "phase-1a" },
});
expect(onMessage).toHaveBeenCalledWith({
type: "update_agent_response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
},
});
expect(directStorageWrite).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,129 @@
```typescript
const server = new McpServer({
name: "my-app",
version: "1.0.0",
});
// Simple tool with parameters
server.registerTool(
"calculate-bmi",
{
title: "BMI Calculator",
description: "Calculate Body Mass Index",
inputSchema: {
weightKg: z.number(),
heightM: z.number(),
},
outputSchema: { bmi: z.number() },
},
async ({ weightKg, heightM }) => {
const output = { bmi: weightKg / (heightM * heightM) };
return {
content: [
{
type: "text",
text: JSON.stringify(output),
},
],
structuredContent: output,
};
}
);
// Async tool with external API call
server.registerTool(
"fetch-weather",
{
title: "Weather Fetcher",
description: "Get weather data for a city",
inputSchema: { city: z.string() },
outputSchema: { temperature: z.number(), conditions: z.string() },
},
async ({ city }) => {
const response = await fetch(`https://api.weather.com/${city}`);
const data = await response.json();
const output = { temperature: data.temp, conditions: data.conditions };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
// Tool that returns ResourceLinks
server.registerTool(
"list-files",
{
title: "List Files",
description: "List project files",
inputSchema: { pattern: z.string() },
outputSchema: {
count: z.number(),
files: z.array(z.object({ name: z.string(), uri: z.string() })),
},
},
async ({ pattern }) => {
const output = {
count: 2,
files: [
{ name: "README.md", uri: "file:///project/README.md" },
{ name: "index.ts", uri: "file:///project/src/index.ts" },
],
};
return {
content: [
{ type: "text", text: JSON.stringify(output) },
// ResourceLinks let tools return references without file content
{
type: "resource_link",
uri: "file:///project/README.md",
name: "README.md",
mimeType: "text/markdown",
description: "A README file",
},
{
type: "resource_link",
uri: "file:///project/src/index.ts",
name: "index.ts",
mimeType: "text/typescript",
description: "An index file",
},
],
structuredContent: output,
};
}
);
```
```typescript
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
bench('InMemoryTransport', async () => {
const client = new Client(
{
name: 'test client',
version: '1.0',
},
{
capabilities: {
tools: {},
},
},
);
const server = createServer();
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const tools = await client.listTools();
if (!tools.tools.length) throw new Error('No tools found');
await client.close();
});
});
```
@@ -0,0 +1,7 @@
export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js";
export { TerminalManager } from "./terminal-manager.js";
export type {
TerminalInfo,
CreateTerminalParams,
CreateTerminalResult,
} from "./terminal-manager.js";
@@ -0,0 +1,266 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import { TerminalManager } from "./terminal-manager.js";
export interface TerminalMcpServerOptions {
sessionName: string;
}
/**
* Create and configure the Terminal MCP Server
* Multiple instances can run independently with different session names
*/
export async function createTerminalMcpServer(
options: TerminalMcpServerOptions,
): Promise<McpServer> {
const { sessionName } = options;
const terminalManager = new TerminalManager(sessionName);
// Initialize the session
await terminalManager.initialize();
const server = new McpServer({
name: "terminal-mcp",
version: "1.0.0",
});
// COMMAND-BASED TOOLS
// Tool: execute_command (disabled – commands must be run through coding agents)
// server.registerTool(
// "execute_command",
// {
// title: "Execute Command",
// description:
// "Execute a shell command in a specified directory. Commands are wrapped in bash -c, so you can use pipes, operators, redirects, and all bash features. The command runs until completion or until output stabilizes (for interactive processes). Returns command ID for follow-up interactions, output, exit code (if finished), and whether the process is still running. Windows remain after command exits for inspection.",
// inputSchema: {
// command: z
// .string()
// .describe(
// "The command to execute. Can include pipes (|), operators (&&, ||, ;), redirects (>, >>), and any bash syntax. Examples: 'npm test', 'ls | grep foo', 'cd src && npm run build'"
// ),
// directory: z
// .string()
// .describe(
// "Absolute path to the working directory. Can use ~ for home directory."
// ),
// maxWait: z
// .number()
// .optional()
// .describe(
// "Maximum milliseconds to wait for command completion or output stability (default: 120000 = 2 minutes). For interactive commands, returns when output stabilizes. For one-shot commands, returns when command exits."
// ),
// },
// outputSchema: {
// commandId: z.string(),
// output: z.string(),
// exitCode: z.number().nullable(),
// isDead: z.boolean(),
// },
// },
// async ({ command, directory, maxWait }) => {
// const result = await terminalManager.executeCommand(
// command,
// directory,
// maxWait
// );
// return {
// content: [],
// structuredContent: result,
// };
// }
// );
// Tool: list_commands
server.registerTool(
"list_commands",
{
title: "List Commands",
description:
"List all commands (both running and exited). Shows command ID, working directory, current process, whether it has exited (isDead), exit code (if exited), and last few lines of output. Includes dead commands that remain for inspection until explicitly killed.",
inputSchema: {},
outputSchema: {
commands: z.array(
z.object({
id: z.string(),
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
isDead: z.boolean(),
exitCode: z.number().nullable(),
lastLines: z.string().nullable(),
}),
),
},
},
async () => {
const commands = await terminalManager.listCommands();
const output = { commands };
return {
content: [],
structuredContent: ensureValidJson(output),
};
},
);
// Tool: capture_command
server.registerTool(
"capture_command",
{
title: "Capture Command Output",
description:
"Capture output from a command by its ID. Works for both running and exited commands. Returns output, exit code (if exited), and whether the command has finished.",
inputSchema: {
commandId: z
.string()
.describe(
"Command ID (window ID like @123) returned from execute_command or list_commands",
),
lines: z.number().optional().describe("Number of lines to capture (default: 200)"),
},
outputSchema: {
output: z.string(),
exitCode: z.number().nullable(),
isDead: z.boolean(),
},
},
async ({ commandId, lines }) => {
const result = await terminalManager.captureCommand(commandId, lines);
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: send_text_to_command
server.registerTool(
"send_text_to_command",
{
title: "Send Text to Command",
description:
"Send text input to a running command (by ID). Use this for interactive processes like REPLs, prompts, or text-based interfaces. Only works if the command is still running (isDead=false).",
inputSchema: {
commandId: z
.string()
.describe("Command ID (window ID like @123) from execute_command or list_commands"),
text: z.string().describe("Text to send to the command"),
pressEnter: z
.boolean()
.optional()
.describe("Press Enter after typing text (default: false)"),
return_output: z
.object({
lines: z.number().optional().describe("Number of lines to capture (default: 200)"),
waitForSettled: z
.boolean()
.optional()
.describe("Wait for output to stabilize before returning (default: true)"),
maxWait: z
.number()
.optional()
.describe("Maximum milliseconds to wait for stability (default: 120000)"),
})
.optional()
.describe("Capture output after sending text"),
},
outputSchema: {
output: z.string().nullable(),
},
},
async ({ commandId, text, pressEnter, return_output }) => {
const output = await terminalManager.sendTextToCommand(
commandId,
text,
pressEnter,
return_output,
);
const result = { output: output || null };
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: send_keys_to_command
server.registerTool(
"send_keys_to_command",
{
title: "Send Keys to Command",
description:
"Send special keys or key combinations to a running command. Use for control sequences and navigation. Examples: 'C-c' (Ctrl+C), 'Enter', 'Escape', 'BTab' (Shift+Tab). Only works if command is still running.",
inputSchema: {
commandId: z.string().describe("Command ID (window ID like @123)"),
keys: z
.string()
.describe("Special key or combination: 'C-c', 'Enter', 'Escape', 'BTab', etc."),
repeat: z
.number()
.optional()
.describe("Number of times to repeat the key press (default: 1)"),
return_output: z
.object({
lines: z.number().optional(),
waitForSettled: z.boolean().optional(),
maxWait: z.number().optional(),
})
.optional()
.describe("Capture output after sending keys"),
},
outputSchema: {
output: z.string().nullable(),
},
},
async ({ commandId, keys, repeat, return_output }) => {
const output = await terminalManager.sendKeysToCommand(
commandId,
keys,
repeat,
return_output,
);
const result = { output: output || null };
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: kill_command
server.registerTool(
"kill_command",
{
title: "Kill Command",
description:
"Kill one or more commands and close their windows. Use this to terminate running processes or clean up finished commands. This removes the commands from list_commands.",
inputSchema: {
commandIds: z
.array(z.string())
.min(1)
.describe(
"Array of command IDs (window IDs like @123) from execute_command or list_commands. Can be a single ID or multiple IDs.",
),
},
outputSchema: {
success: z.boolean(),
killedCount: z.number(),
},
},
async ({ commandIds }) => {
let killedCount = 0;
for (const commandId of commandIds) {
await terminalManager.killCommand(commandId);
killedCount++;
}
const output = { success: true, killedCount };
return {
content: [],
structuredContent: ensureValidJson(output),
};
},
);
return server;
}
@@ -0,0 +1,475 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { TerminalManager } from "./terminal-manager.js";
import { findSessionByName, killSession } from "./tmux.js";
const TEST_SESSION = `test-terminal-manager-${process.pid}-${Date.now().toString(36)}`;
const ANSI_ESCAPE_REGEX = /\u001B[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_REGEX, "");
}
function expectMissingPathMessage(output: string, missingPath: string): void {
const normalized = stripAnsi(output).toLowerCase();
expect(normalized).toContain(missingPath.toLowerCase());
expect(normalized).toMatch(/cannot access|no such file|not found/);
}
describe("TerminalManager - Command Execution", () => {
let manager: TerminalManager;
beforeAll(async () => {
manager = new TerminalManager(TEST_SESSION);
await manager.initialize();
});
afterAll(async () => {
// Cleanup test session
const session = await findSessionByName(TEST_SESSION);
if (session) {
await killSession(session.id);
}
});
describe("executeCommand", () => {
it("should execute a simple one-shot command and return exit code", async () => {
const result = await manager.executeCommand(
"echo 'Hello World'",
process.env.HOME || "~",
5000,
);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain("Hello World");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle commands with pipes and operators", async () => {
const result = await manager.executeCommand(
"echo 'line1\nline2\nline3' | grep line2",
process.env.HOME || "~",
5000,
);
expect(result.output).toContain("line2");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should capture non-zero exit codes", async () => {
const result = await manager.executeCommand(
"ls /nonexistent-directory-test 2>&1",
process.env.HOME || "~",
5000,
);
expect(result.isDead).toBe(true);
expect(result.exitCode).not.toBe(0);
expectMissingPathMessage(result.output, "nonexistent-directory-test");
});
it("should handle directory changes in command", async () => {
const result = await manager.executeCommand("cd /tmp && pwd", process.env.HOME || "~", 5000);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should launch interactive command (Python REPL)", async () => {
const result = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain(">>>"); // Python prompt
expect(result.isDead).toBe(false); // Still running
expect(result.exitCode).toBeNull(); // No exit code yet
// Cleanup
await manager.killCommand(result.commandId);
});
it("should handle command with working directory", async () => {
const result = await manager.executeCommand("pwd", "/tmp", 5000);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle long-running command with timeout", async () => {
const result = await manager.executeCommand(
"sleep 0.5 && echo 'Done sleeping'",
process.env.HOME || "~",
3000, // Increased timeout to ensure command completes
);
expect(result.output).toContain("Done sleeping");
// Note: May still be alive if not enough time to detect completion
if (result.isDead) {
expect(result.exitCode).toBe(0);
}
});
});
describe("listCommands", () => {
it("should list all commands including dead ones", async () => {
// Execute a command that finishes
const result1 = await manager.executeCommand("echo 'test1'", process.env.HOME || "~", 5000);
// Execute a command that stays running
const result2 = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
const commands = await manager.listCommands();
expect(commands.length).toBeGreaterThanOrEqual(2);
const cmd1 = commands.find((c) => c.id === result1.commandId);
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
const cmd2 = commands.find((c) => c.id === result2.commandId);
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(false);
expect(cmd2?.exitCode).toBeNull();
// Cleanup
await manager.killCommand(result2.commandId);
});
it("should preserve command and working directory info for dead commands", async () => {
const testDir1 = "/tmp";
const testCommand1 = "echo 'test message 1' && pwd";
const testDir2 = process.env.HOME || "~";
const testCommand2 = "echo 'test message 2'";
// Execute two different commands
const result1 = await manager.executeCommand(testCommand1, testDir1, 5000);
const result2 = await manager.executeCommand(testCommand2, testDir2, 5000);
expect(result1.isDead).toBe(true);
expect(result1.exitCode).toBe(0);
expect(result2.isDead).toBe(true);
expect(result2.exitCode).toBe(0);
// List commands - should show the original command and working directory for each
const commands = await manager.listCommands();
const cmd1 = commands.find((c) => c.id === result1.commandId);
const cmd2 = commands.find((c) => c.id === result2.commandId);
// Verify first command
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
expect(cmd1?.workingDirectory).toBe(testDir1);
expect(cmd1?.currentCommand).toBe(testCommand1);
// Verify second command (should have different values)
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(true);
expect(cmd2?.exitCode).toBe(0);
expect(cmd2?.workingDirectory).toBe(testDir2);
expect(cmd2?.currentCommand).toBe(testCommand2);
// Ensure they're actually different
expect(cmd1?.workingDirectory).not.toBe(cmd2?.workingDirectory);
expect(cmd1?.currentCommand).not.toBe(cmd2?.currentCommand);
// Cleanup
await manager.killCommand(result1.commandId);
await manager.killCommand(result2.commandId);
});
});
describe("captureCommand", () => {
it("should capture output from finished command", async () => {
const execResult = await manager.executeCommand(
"echo 'Capture test'",
process.env.HOME || "~",
5000,
);
const captureResult = await manager.captureCommand(execResult.commandId, 100);
expect(captureResult.output).toContain("Capture test");
expect(captureResult.isDead).toBe(true);
expect(captureResult.exitCode).toBe(0);
});
it("should capture output from running command", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
const captureResult = await manager.captureCommand(execResult.commandId, 50);
expect(captureResult.output).toContain(">>>");
expect(captureResult.isDead).toBe(false);
expect(captureResult.exitCode).toBeNull();
// Cleanup
await manager.killCommand(execResult.commandId);
});
});
describe("sendTextToCommand", () => {
it("should send text to running Python REPL", async () => {
// Launch Python
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Send Python code
const output = await manager.sendTextToCommand(
execResult.commandId,
"print('Hello from Python')",
true, // press Enter
{ lines: 50, maxWait: 3000, waitForSettled: true },
);
expect(output).toContain("Hello from Python");
// Send exit
await manager.sendTextToCommand(execResult.commandId, "exit()", true, {
lines: 50,
maxWait: 2000,
});
// Verify command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle multiple sequential inputs to REPL", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
await manager.sendTextToCommand(execResult.commandId, "x = 5", true);
await manager.sendTextToCommand(execResult.commandId, "y = 3", true);
const output = await manager.sendTextToCommand(execResult.commandId, "print(x + y)", true, {
lines: 50,
maxWait: 2000,
});
expect(output).toContain("8");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
});
describe("sendKeysToCommand", () => {
it("should send Ctrl-C to interrupt running command", async () => {
// Start a long-running command
const execResult = await manager.executeCommand(
"sleep 100",
process.env.HOME || "~",
1000, // Short wait, won't finish
);
expect(execResult.isDead).toBe(false);
// Send Ctrl-C
await manager.sendKeysToCommand(execResult.commandId, "C-c", 1, { lines: 50, maxWait: 2000 });
// Wait a bit for signal to process
await new Promise((resolve) => setTimeout(resolve, 500));
// Check if command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should send Enter key to REPL", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Type without pressing Enter
await manager.sendTextToCommand(
execResult.commandId,
"print('test')",
false, // Don't press Enter
);
// Now send Enter via sendKeys
const output = await manager.sendKeysToCommand(execResult.commandId, "Enter", 1, {
lines: 50,
maxWait: 2000,
});
expect(output).toContain("test");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
it("should repeat key press multiple times", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Send multiple Ctrl-C (should exit Python)
await manager.sendKeysToCommand(
execResult.commandId,
"C-c",
3, // Repeat 3 times
{ lines: 50, maxWait: 3000 },
);
// Wait longer for Python to exit
await new Promise((resolve) => setTimeout(resolve, 1500));
const captureResult = await manager.captureCommand(execResult.commandId);
// Python might not exit from Ctrl-C depending on state, so just verify we can still capture
expect(captureResult.output).toBeDefined();
// Cleanup if still running
if (!captureResult.isDead) {
await manager.killCommand(execResult.commandId);
}
});
});
describe("killCommand", () => {
it("should kill running command", async () => {
const execResult = await manager.executeCommand("sleep 100", process.env.HOME || "~", 1000);
expect(execResult.isDead).toBe(false);
// Kill the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
it("should kill finished command (cleanup)", async () => {
const execResult = await manager.executeCommand(
"echo 'cleanup test'",
process.env.HOME || "~",
5000,
);
expect(execResult.isDead).toBe(true);
// Kill/cleanup the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
});
describe("Complex workflows", () => {
it("should handle Node.js REPL workflow", async () => {
// Launch Node REPL
const execResult = await manager.executeCommand("node", process.env.HOME || "~", 5000);
expect(execResult.output).toContain(">"); // Node prompt
expect(execResult.isDead).toBe(false);
await manager.sendTextToCommand(execResult.commandId, "const x = [1, 2, 3]", true);
const output2 = await manager.sendTextToCommand(
execResult.commandId,
"console.log(x.map(n => n * 2).join(','))",
true,
{ lines: 50, maxWait: 2000 },
);
expect(output2).toContain("2,4,6");
// Exit
await manager.sendTextToCommand(execResult.commandId, ".exit", true);
// Verify exited
await new Promise((resolve) => setTimeout(resolve, 500));
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle command that produces streaming output", async () => {
const execResult = await manager.executeCommand(
"for i in 1 2 3; do echo Line $i; sleep 0.1; done",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("Line 1");
expect(execResult.output).toContain("Line 2");
expect(execResult.output).toContain("Line 3");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with stderr output", async () => {
const execResult = await manager.executeCommand(
"echo 'to stdout'; echo 'to stderr' >&2",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("to stdout");
expect(execResult.output).toContain("to stderr");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
});
describe("Edge cases", () => {
it("should handle empty command output", async () => {
const execResult = await manager.executeCommand(
"true", // Command that produces no output
process.env.HOME || "~",
5000,
);
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with tilde in directory", async () => {
const execResult = await manager.executeCommand("pwd", "~/", 5000);
expect(execResult.output).toContain(process.env.HOME || "");
expect(execResult.isDead).toBe(true);
});
it("should handle very long output", async () => {
const execResult = await manager.executeCommand(
"seq 1 500", // Generate 500 lines
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("1");
expect(execResult.output).toContain("500");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle special characters in command", async () => {
const execResult = await manager.executeCommand(
"echo 'Special: $test & | ; < >'",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("Special:");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with quotes", async () => {
const execResult = await manager.executeCommand(
`echo 'double and single quotes'`,
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("double");
expect(execResult.output).toContain("single");
expect(execResult.isDead).toBe(true);
});
});
});
@@ -0,0 +1,502 @@
import {
findSessionByName,
createSession,
listWindows,
createWindow,
listPanes,
findWindowByName,
capturePaneContent,
extractExitCodeMarkerFromOutput,
sendText as tmuxSendText,
sendKeys as tmuxSendKeys,
renameWindow,
killWindow,
isWindowNameUnique,
getCurrentWorkingDirectory,
getCurrentCommand,
getStoredWorkingDirectory,
getStoredCommand,
waitForPaneActivityToSettle,
executeCommand as tmuxExecuteCommand,
executeTmux,
} from "./tmux.js";
// Terminal model: session → windows (single pane per window)
// Terminals are identified by their unique names, not IDs
export interface TerminalInfo {
name: string;
workingDirectory: string;
currentCommand: string;
lastLines: string | null;
}
export interface CommandInfo {
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
lastLines: string | null;
}
export interface CreateTerminalParams {
name: string;
workingDirectory: string;
initialCommand?: string;
}
export interface CreateTerminalResult extends TerminalInfo {
commandOutput: string | null;
}
/**
* Terminal manager for a specific tmux session
* Multiple instances can coexist with different session names
*/
export class TerminalManager {
constructor(private sessionName: string) {}
/**
* Initialize the tmux session
* Creates it if it doesn't exist
*/
async initialize(): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
await createSession(this.sessionName);
}
}
/**
* List all terminals in the session
* Returns terminal info including name, active status, working directory, and current command
*/
async listTerminals(): Promise<TerminalInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const windows = await listWindows(session.id);
const terminals: TerminalInfo[] = [];
for (const window of windows) {
// Get the first (and only) pane in this window
const paneId = `${window.id}.0`;
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
const lastLines = await capturePaneContent(paneId, 5, false);
terminals.push({
name: window.name,
workingDirectory,
currentCommand,
lastLines: lastLines || null,
});
}
return terminals;
}
/**
* Create a new terminal (tmux window) with specified name and working directory
* Optionally execute an initial command
*/
async createTerminal(params: CreateTerminalParams): Promise<CreateTerminalResult> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
// Validate name uniqueness
const isUnique = await isWindowNameUnique(session.id, params.name);
if (!isUnique) {
throw new Error(
`Terminal with name '${params.name}' already exists. Please choose a unique name.`,
);
}
// Create the window
const windowResult = await createWindow(session.id, params.name, {
workingDirectory: params.workingDirectory,
command: params.initialCommand ?? null,
});
if (!windowResult) {
throw new Error(`Failed to create terminal '${params.name}'`);
}
const paneId = windowResult.paneId;
// Get terminal info
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
return {
name: windowResult.name,
workingDirectory,
currentCommand,
commandOutput: windowResult.output || null,
lastLines: null,
};
}
/**
* Capture output from a terminal by name
* Returns the last N lines of terminal content
* If maxWait is provided, waits for terminal activity to settle before capturing
*/
async captureTerminal(
terminalName: string,
lines: number = 200,
maxWait?: number,
): Promise<string> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
// Wait for activity to settle if maxWait is provided
if (maxWait) {
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
}
return capturePaneContent(pane.id, lines, false);
}
/**
* Send text to a terminal by name, optionally press Enter, optionally return output
*/
async sendText(
terminalName: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendText({
paneId: pane.id,
text,
pressEnter,
return_output,
});
}
/**
* Send special keys or key combinations to a terminal by name
* Useful for TUI navigation, control sequences, and interactive applications
*/
async sendKeys(
terminalName: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendKeys({
paneId: pane.id,
keys,
repeat,
return_output,
});
}
/**
* Rename a terminal by name
* Validates that the new name is unique
*/
async renameTerminal(terminalName: string, newName: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// renameWindow handles uniqueness validation and name resolution internally
await renameWindow(session.id, terminalName, newName);
}
/**
* Kill (close/destroy) a terminal by name
*/
async killTerminal(terminalName: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
await killWindow(window.id);
}
/**
* Execute a command and return its output, exit code, and running status
* Commands are wrapped in bash -c to support all bash features
* Windows remain after command exits (remain-on-exit) for inspection
*/
async executeCommand(
command: string,
workingDirectory: string,
maxWait?: number,
): Promise<{
commandId: string;
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const result = await tmuxExecuteCommand({
sessionId: session.id,
command,
workingDirectory,
maxWait,
});
return {
commandId: result.windowId,
output: result.output,
exitCode: result.exitCode,
isDead: result.isDead,
};
}
/**
* List all commands (windows), including those that have exited
* Shows both running and dead commands for inspection
*/
async listCommands(): Promise<CommandInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const windows = await listWindows(session.id);
const commands: CommandInfo[] = [];
for (const window of windows) {
const paneId = `${window.id}.0`;
// Check if pane is dead first
const deadStatus = await executeTmux(["display-message", "-p", "-t", paneId, "#{pane_dead}"]);
const isDead = deadStatus === "1";
// Use stored values for dead panes, current values for live ones
const workingDirectory = await getStoredWorkingDirectory(window.id, paneId);
const currentCommand = await getStoredCommand(window.id, paneId);
const rawLastLines = await capturePaneContent(paneId, 5, false);
const lastLinesExtracted = extractExitCodeMarkerFromOutput(rawLastLines);
const lastLines = lastLinesExtracted.output;
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
const parsed = parseInt(exitCodeStr, 10);
exitCode = Number.isFinite(parsed) ? parsed : lastLinesExtracted.exitCode;
}
commands.push({
id: window.id,
name: window.name,
workingDirectory,
currentCommand,
isDead,
exitCode,
lastLines: lastLines || null,
});
}
return commands;
}
/**
* Capture output from a command by ID (window ID)
*/
async captureCommand(
commandId: string,
lines: number = 200,
): Promise<{
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
const rawOutput = await capturePaneContent(paneId, lines, false);
const extracted = extractExitCodeMarkerFromOutput(rawOutput);
const output = extracted.output;
const deadStatus = await executeTmux(["display-message", "-p", "-t", paneId, "#{pane_dead}"]);
const isDead = deadStatus === "1";
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
const parsed = parseInt(exitCodeStr, 10);
exitCode = Number.isFinite(parsed) ? parsed : extracted.exitCode;
}
return {
output,
exitCode,
isDead,
};
}
/**
* Send text to a command by ID (window ID)
*/
async sendTextToCommand(
commandId: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendText({
paneId,
text,
pressEnter,
return_output,
});
}
/**
* Send keys to a command by ID (window ID)
*/
async sendKeysToCommand(
commandId: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendKeys({
paneId,
keys,
repeat,
return_output,
});
}
/**
* Kill a command by ID (window ID)
*/
async killCommand(commandId: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
await killWindow(commandId);
}
}
File diff suppressed because it is too large Load Diff
@@ -63,6 +63,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
const agentManager = {
setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null),
getLastAssistantMessage: vi.fn(async () => null),
...agentManagerOverrides,
};
@@ -92,22 +93,20 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
vi.clearAllMocks();
});
it("uses assistant preview text for push notifications with markdown removed", () => {
it("uses assistant preview text for push notifications with markdown removed", async () => {
const getLastAssistantMessage = vi.fn(
async () => "**Done**. Updated `README.md` and [link](https://example.com).",
);
const { server } = createServer({
getAgent: vi.fn(() => ({
config: { title: null },
cwd: "/tmp/worktree",
timeline: [
{
type: "assistant_message",
text: "**Done**. Updated `README.md` and [link](https://example.com).",
},
],
pendingPermissions: new Map(),
})),
getLastAssistantMessage,
});
(server as any).broadcastAgentAttention({
await (server as any).broadcastAgentAttention({
agentId: "agent-1",
provider: "claude",
reason: "finished",
@@ -122,30 +121,28 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
reason: "finished",
},
});
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1");
});
it("sends push notifications regardless of UI label presence", () => {
it("sends push notifications regardless of UI label presence", async () => {
const getLastAssistantMessage = vi.fn(async () => "Done.");
const { server } = createServer({
getAgent: vi.fn(() => ({
config: { title: null },
cwd: "/tmp/worktree",
labels: {},
timeline: [
{
type: "assistant_message",
text: "Done.",
},
],
pendingPermissions: new Map(),
})),
getLastAssistantMessage,
});
(server as any).broadcastAgentAttention({
await (server as any).broadcastAgentAttention({
agentId: "agent-2",
provider: "claude",
reason: "finished",
});
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
});
});
+10 -8
View File
@@ -4,7 +4,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { join } from "path";
import { hostname as getHostname } from "node:os";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
@@ -42,7 +42,6 @@ import {
} from "./agent-attention-policy.js";
import {
buildAgentAttentionNotificationPayload,
findLatestAssistantMessageFromTimeline,
findLatestPermissionRequest,
} from "../shared/agent-attention-notification.js";
@@ -224,7 +223,7 @@ export class VoiceAssistantWebSocketServer {
private readonly serverId: string;
private readonly daemonVersion: string;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly agentStorage: AgentSnapshotStore;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly downloadTokenStore: DownloadTokenStore;
@@ -282,7 +281,7 @@ export class VoiceAssistantWebSocketServer {
logger: pino.Logger,
serverId: string,
agentManager: AgentManager,
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
downloadTokenStore: DownloadTokenStore,
paseoHome: string,
createAgentMcpTransport: AgentMcpTransportFactory,
@@ -343,7 +342,9 @@ export class VoiceAssistantWebSocketServer {
this.pushService = new PushService(pushLogger, this.pushTokenStore);
this.agentManager.setAgentAttentionCallback((params) => {
this.broadcastAgentAttention(params);
void this.broadcastAgentAttention(params).catch((err) => {
this.logger.warn({ err, agentId: params.agentId }, "Failed to broadcast agent attention");
});
});
const { allowedOrigins, allowedHosts } = wsConfig;
@@ -1240,11 +1241,11 @@ export class VoiceAssistantWebSocketServer {
};
}
private broadcastAgentAttention(params: {
private async broadcastAgentAttention(params: {
agentId: string;
provider: AgentProvider;
reason: "finished" | "error" | "permission";
}): void {
}): Promise<void> {
const clientEntries: Array<{
ws: WebSocketLike;
state: ClientAttentionState;
@@ -1259,11 +1260,12 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state);
const agent = this.agentManager.getAgent(params.agentId);
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
const notification = buildAgentAttentionNotificationPayload({
reason: params.reason,
serverId: this.serverId,
agentId: params.agentId,
assistantMessage: agent ? findLatestAssistantMessageFromTimeline(agent.timeline) : null,
assistantMessage,
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
});
@@ -0,0 +1,413 @@
import { describe, expect, test, vi } from "vitest";
import type { ProjectPlacementPayload } from "./messages.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
function createProjectPlacement(input: {
cwd: string;
projectId: string;
projectName: string;
isGit: boolean;
branchName?: string | null;
remoteUrl?: string | null;
isPaseoOwnedWorktree?: boolean;
mainRepoRoot?: string | null;
}): ProjectPlacementPayload {
return {
projectKey: input.projectId,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: input.isGit,
currentBranch: input.branchName ?? null,
remoteUrl: input.remoteUrl ?? null,
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
mainRepoRoot: input.mainRepoRoot ?? null,
},
};
}
function createInMemoryRegistries() {
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
return {
projects,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (projectId: string) => projects.get(projectId) ?? null,
upsert: async (record: ReturnType<typeof createPersistedProjectRecord>) => {
projects.set(record.projectId, record);
},
archive: async (projectId: string, archivedAt: string) => {
const existing = projects.get(projectId);
if (!existing) {
return;
}
projects.set(projectId, {
...existing,
archivedAt,
updatedAt: archivedAt,
});
},
remove: async (projectId: string) => {
projects.delete(projectId);
},
},
workspaces,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
upsert: async (record: ReturnType<typeof createPersistedWorkspaceRecord>) => {
workspaces.set(record.workspaceId, record);
},
archive: async (workspaceId: string, archivedAt: string) => {
const existing = workspaces.get(workspaceId);
if (!existing) {
return;
}
workspaces.set(workspaceId, {
...existing,
archivedAt,
updatedAt: archivedAt,
});
},
remove: async (workspaceId: string) => {
workspaces.delete(workspaceId);
},
},
};
}
describe("WorkspaceReconciliationService", () => {
test("reconcileWorkspaceRecord registers a new workspace and project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const syncWorkspaceGitWatchTarget = vi.fn(async () => {});
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: cwd,
projectName: "repo",
isGit: false,
}),
syncWorkspaceGitWatchTarget,
removeWorkspaceGitWatchTarget: vi.fn(),
now: () => "2026-03-25T00:00:00.000Z",
});
const result = await service.reconcileWorkspaceRecord("/tmp/repo");
expect(result).toEqual({
changed: true,
workspace: createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "/tmp/repo",
cwd: "/tmp/repo",
kind: "directory",
displayName: "repo",
createdAt: "2026-03-25T00:00:00.000Z",
updatedAt: "2026-03-25T00:00:00.000Z",
}),
});
expect(projects.get("/tmp/repo")).toEqual(
createPersistedProjectRecord({
projectId: "/tmp/repo",
rootPath: "/tmp/repo",
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-25T00:00:00.000Z",
updatedAt: "2026-03-25T00:00:00.000Z",
}),
);
expect(workspaces.get("/tmp/repo")).toEqual(result.workspace);
expect(syncWorkspaceGitWatchTarget).toHaveBeenCalledWith("/tmp/repo", {
isGit: false,
});
});
test("registerPendingWorktreeWorkspace preregisters a git worktree under the repo project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const syncWorkspaceGitWatchTarget = vi.fn(async () => {});
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: "remote:github.com/acme/repo",
projectName: "acme/repo",
isGit: true,
branchName: "main",
remoteUrl: "https://github.com/acme/repo.git",
}),
syncWorkspaceGitWatchTarget,
removeWorkspaceGitWatchTarget: vi.fn(),
now: () => "2026-03-25T01:00:00.000Z",
});
const workspace = await service.registerPendingWorktreeWorkspace({
repoRoot: "/tmp/repo",
worktreePath: "/tmp/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
});
expect(workspace).toEqual(
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo/.paseo/worktrees/feature-a",
projectId: "remote:github.com/acme/repo",
cwd: "/tmp/repo/.paseo/worktrees/feature-a",
kind: "worktree",
displayName: "feature-a",
createdAt: "2026-03-25T01:00:00.000Z",
updatedAt: "2026-03-25T01:00:00.000Z",
}),
);
expect(projects.get("remote:github.com/acme/repo")).toEqual(
createPersistedProjectRecord({
projectId: "remote:github.com/acme/repo",
rootPath: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
createdAt: "2026-03-25T01:00:00.000Z",
updatedAt: "2026-03-25T01:00:00.000Z",
}),
);
expect(workspaces.get("/tmp/repo/.paseo/worktrees/feature-a")).toEqual(workspace);
expect(syncWorkspaceGitWatchTarget).toHaveBeenCalledWith(
"/tmp/repo/.paseo/worktrees/feature-a",
{
isGit: true,
},
);
});
test("reconcileActiveWorkspaceRecords reassigns drifted workspaces and archives the old empty project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const localProjectId = "/tmp/repo";
const remoteProjectId = "remote:github.com/acme/repo";
const mainWorkspaceId = "/tmp/repo";
const worktreeWorkspaceId = "/tmp/repo/.paseo/worktrees/feature-a";
projects.set(
localProjectId,
createPersistedProjectRecord({
projectId: localProjectId,
rootPath: mainWorkspaceId,
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
mainWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: mainWorkspaceId,
projectId: localProjectId,
cwd: mainWorkspaceId,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
worktreeWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: worktreeWorkspaceId,
projectId: localProjectId,
cwd: worktreeWorkspaceId,
kind: "worktree",
displayName: "feature-a",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: remoteProjectId,
projectName: "acme/repo",
isGit: true,
branchName: cwd === mainWorkspaceId ? "main" : "feature-a",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
}),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget: vi.fn(),
checkDirectoryExists: async () => true,
now: () => "2026-03-25T02:00:00.000Z",
});
const changedWorkspaceIds = await service.reconcileActiveWorkspaceRecords();
expect(Array.from(changedWorkspaceIds).sort()).toEqual([
mainWorkspaceId,
worktreeWorkspaceId,
]);
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId);
expect(projects.get(localProjectId)?.archivedAt).toBe("2026-03-25T02:00:00.000Z");
});
test("reconcileActiveWorkspaceRecords archives missing-dir and all-archived-agent workspaces but keeps no-agent and active-agent cases", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const activeWorkspaceId = "/tmp/active";
const noAgentWorkspaceId = "/tmp/no-agents";
const missingWorkspaceId = "/tmp/missing";
const archivedAgentsWorkspaceId = "/tmp/all-archived";
for (const workspaceId of [
activeWorkspaceId,
noAgentWorkspaceId,
missingWorkspaceId,
archivedAgentsWorkspaceId,
]) {
projects.set(
workspaceId,
createPersistedProjectRecord({
projectId: workspaceId,
rootPath: workspaceId,
kind: "non_git",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd: workspaceId,
kind: "directory",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
}
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [
{
id: "archived-agent",
cwd: archivedAgentsWorkspaceId,
archivedAt: "2026-03-02T00:00:00.000Z",
},
{
id: "inactive-agent",
cwd: activeWorkspaceId,
archivedAt: "2026-03-02T00:00:00.000Z",
},
{
id: "active-agent",
cwd: activeWorkspaceId,
archivedAt: null,
},
],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: cwd,
projectName: cwd.split("/").at(-1) ?? cwd,
isGit: false,
}),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget: vi.fn(),
checkDirectoryExists: async (cwd: string) => cwd !== missingWorkspaceId,
now: () => "2026-03-25T03:00:00.000Z",
});
const changedWorkspaceIds = await service.reconcileActiveWorkspaceRecords();
expect(Array.from(changedWorkspaceIds).sort()).toEqual([
archivedAgentsWorkspaceId,
missingWorkspaceId,
]);
expect(workspaces.get(missingWorkspaceId)?.archivedAt).toBe("2026-03-25T03:00:00.000Z");
expect(workspaces.get(archivedAgentsWorkspaceId)?.archivedAt).toBe(
"2026-03-25T03:00:00.000Z",
);
expect(workspaces.get(noAgentWorkspaceId)?.archivedAt).toBeNull();
expect(workspaces.get(activeWorkspaceId)?.archivedAt).toBeNull();
});
test("archiveWorkspaceRecord archives the last active workspace and its project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const removeWorkspaceGitWatchTarget = vi.fn();
const projectId = "/tmp/repo";
const workspaceId = "/tmp/repo";
projects.set(
projectId,
createPersistedProjectRecord({
projectId,
rootPath: workspaceId,
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId,
cwd: workspaceId,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: vi.fn(),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget,
now: () => "2026-03-25T04:00:00.000Z",
});
await service.archiveWorkspaceRecord(workspaceId);
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-25T04:00:00.000Z");
expect(projects.get(projectId)?.archivedAt).toBe("2026-03-25T04:00:00.000Z");
expect(removeWorkspaceGitWatchTarget).toHaveBeenCalledWith(workspaceId);
});
});
@@ -0,0 +1,296 @@
import { stat } from "node:fs/promises";
import type { ProjectPlacementPayload } from "./messages.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import {
detectStaleWorkspaces,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
} from "./workspace-registry-model.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
type Awaitable<T> = T | Promise<T>;
export type WorkspaceReconciliationServiceOptions = {
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
agentStorage: Pick<AgentSnapshotStore, "list">;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
syncWorkspaceGitWatchTarget?: (
cwd: string,
options: { isGit: boolean },
) => Awaitable<void>;
removeWorkspaceGitWatchTarget?: (cwd: string) => Awaitable<void>;
checkDirectoryExists?: (cwd: string) => Promise<boolean>;
now?: () => string;
};
export type ReconcileWorkspaceRecordResult = {
workspace: PersistedWorkspaceRecord;
changed: boolean;
};
export class WorkspaceReconciliationService {
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly agentStorage: Pick<AgentSnapshotStore, "list">;
private readonly buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
private readonly syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
private readonly removeWorkspaceGitWatchTarget: (cwd: string) => Promise<void>;
private readonly checkDirectoryExists: (cwd: string) => Promise<boolean>;
private readonly now: () => string;
constructor(options: WorkspaceReconciliationServiceOptions) {
this.projectRegistry = options.projectRegistry;
this.workspaceRegistry = options.workspaceRegistry;
this.agentStorage = options.agentStorage;
this.buildProjectPlacement = options.buildProjectPlacement;
this.syncWorkspaceGitWatchTarget = async (cwd, syncOptions) => {
await options.syncWorkspaceGitWatchTarget?.(cwd, syncOptions);
};
this.removeWorkspaceGitWatchTarget = async (cwd) => {
await options.removeWorkspaceGitWatchTarget?.(cwd);
};
this.checkDirectoryExists =
options.checkDirectoryExists ??
(async (cwd) => {
try {
await stat(cwd);
return true;
} catch {
return false;
}
});
this.now = options.now ?? (() => new Date().toISOString());
}
async reconcileWorkspaceRecord(workspaceId: string): Promise<ReconcileWorkspaceRecordResult> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existingWorkspace = await this.workspaceRegistry.get(normalizedWorkspaceId);
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
isGit: placement.checkout.isGit,
});
const now = this.now();
const nextWorkspaceCreatedAt = existingWorkspace?.createdAt ?? now;
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: currentProjectRecord?.createdAt ?? nextWorkspaceCreatedAt,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: nextWorkspaceCreatedAt,
updatedAt: now,
});
const needsWorkspaceUpdate =
!existingWorkspace ||
existingWorkspace.archivedAt ||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId ||
existingWorkspace.kind !== nextWorkspaceRecord.kind ||
existingWorkspace.displayName !== nextWorkspaceRecord.displayName;
const needsProjectUpdate =
!currentProjectRecord ||
currentProjectRecord.archivedAt ||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
currentProjectRecord.kind !== nextProjectRecord.kind ||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
return {
workspace: existingWorkspace!,
changed: false,
};
}
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return {
workspace: nextWorkspaceRecord,
changed: true,
};
}
async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
const changedWorkspaceIds = new Set<string>();
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces,
agentRecords: (await this.agentStorage.list()).map((agent) => ({
cwd: agent.cwd,
archivedAt: agent.archivedAt ?? null,
})),
checkDirectoryExists: this.checkDirectoryExists,
});
for (const workspaceId of staleWorkspaceIds) {
await this.archiveWorkspaceRecord(workspaceId);
changedWorkspaceIds.add(workspaceId);
}
for (const workspace of activeWorkspaces) {
if (staleWorkspaceIds.has(workspace.workspaceId)) {
continue;
}
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
if (result.changed) {
changedWorkspaceIds.add(result.workspace.workspaceId);
}
}
return changedWorkspaceIds;
}
async registerPendingWorktreeWorkspace(options: {
repoRoot: string;
worktreePath: string;
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const now = this.now();
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
const existingProject = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
await this.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
}
async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existingWorkspace = await this.workspaceRegistry.get(normalizedWorkspaceId);
if (!existingWorkspace || existingWorkspace.archivedAt) {
await this.removeWorkspaceGitWatchTarget(normalizedWorkspaceId);
return;
}
const nextArchivedAt = archivedAt ?? this.now();
await this.workspaceRegistry.archive(normalizedWorkspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(normalizedWorkspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt);
}
}
private buildPersistedProjectRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: input.placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
kind: deriveProjectKind(input.placement.checkout),
displayName: input.placement.projectName,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private buildPersistedWorkspaceRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: input.workspaceId,
projectId: input.placement.projectKey,
cwd: input.workspaceId,
kind: deriveWorkspaceKind(input.placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(projectId, archivedAt);
}
}
}

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