Compare commits

..
Author SHA1 Message Date
paseo-bot 224d3d7e17 test(server): synchronize the steer regression test on timeline state
Replace the fixed sleeps with polling assertions, and run the coalescer
with a zero window so every chunk flushes on its leading edge.
2026-09-23 13:06:32 +02:00
paseo-bot 896cd76d67 fix(server): keep a streamed assistant message whole across a steer
Steering an agent mid-stream records the user message at the next
timeline seq, between two chunks of the assistant message that is still
streaming. The projection merged assistant chunks only when they were
adjacent, so the interposed user row split one provider message into two
timeline rows at whatever character had arrived, often mid-word.

An assistant message that carries a provider message id now has a
timeline identity, like a tool call does, so its chunks collapse into one
item wherever they land. Chunks without a message id keep the existing
adjacency merge.
2026-09-23 12:58:13 +02:00
paseo-bot[bot] d161b5987d Dismiss an Android bottom sheet with Back instead of the screen (#5245)
* Dismiss an Android bottom sheet with Back instead of the screen

Bottom sheets render through a portal inside the app's own view tree
rather than a native modal window, so Android offered Back to the
navigator underneath them. On the model selector that meant one Back
press left the chat and the app, with the sheet still open.

`IsolatedBottomSheetModal` now claims `hardwareBackPress` for as long as
the sheet is on screen and dismisses itself instead. Listeners are
offered the press newest-first, so a stacked sheet answers before the one
below it, and Back falls back through to the navigator once no sheet is
left.

* Move the Back press onto the sheet visibility tracker

The first version held the press in `IsolatedBottomSheetModal` with its own
`isOnScreen` state and a merged ref, duplicating the phase the visibility
tracker already keeps, and it could only be tested through JSDOM with
`vi.mock` and `vi.hoisted`, which docs/testing.md rules out.

The tracker already owns presentation state and the sheet controller, so it
takes a `BackPressSource` port and holds the press for exactly the window it
is in the `presented` phase. `IsolatedBottomSheetModal` goes back to what it
was on main. Every sheet call site reaches the tracker through
`useIsolatedBottomSheetVisibility`, so coverage is unchanged.

The real source registers nothing off Android, where there is no such press
and `react-native-web`'s `BackHandler` logs an error on subscribe.

The test is now a plain unit test against a fake adapter, with no mocks,
no JSDOM, and no component mounting.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 08:45:00 +02:00
paseo-bot[bot] 90737e1dec fix(omp): show a stopped OMP turn as canceled, not failed (#5243)
Pressing Stop on an OMP turn could land in the thread as a failed turn
with OMP's interrupt text as the error, because OMP ends the turn with a
terminal response that carries "Interrupted by user" in errorMessage and
"aborted" in stopReason, and completeTurn classified any errorMessage as
a failure.

A terminal response whose stop reason is aborted now ends the turn as
turn_canceled and terminalizes the turn's in-flight tool calls and
subagents, matching what interrupt() already emits when it wins the race
with the provider's teardown.

A provider-idle state check that resolves after the turn already settled
no longer completes that turn, so a stop can no longer emit a second
lifecycle event behind the cancellation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 07:45:48 +02:00
3eaf0be0b2 Send a Claude slash command as the last content block (#5240)
Claude Code expands a slash command only when it is the last content
block of the user message. buildAgentPrompt orders blocks as chat
history, typed text, images, then rendered attachments, so a "/command"
sent alongside a pasted issue link or a screenshot reached the model as
literal text: the command never ran, and the model either called the
Skill tool on its own or answered without it.

toSdkUserMessage now moves the typed block to the end when it parses as
a slash command. Attachments rendered as text always carry a mimeType,
so a quoted "/command" inside chat history is never mistaken for the
invocation. Message order is unchanged when the typed text is not a
slash command.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Joe Corkerton <joecorkerton@users.noreply.github.com>
2026-09-23 06:45:57 +02:00
paseo-bot[bot]andHMWCS 8269a0ea78 Keep approvals with the user in every Codex mode but Auto-review (#5239)
A Codex mode preset named its approvals reviewer only for Auto-review.
Every other mode left the field off the thread/start and turn/start
request, so the Codex app-server fell back to `approvals_reviewer` from
the user's global config, or to whatever reviewer the live thread was
already using. Default Permissions and Read-only then behaved like
Auto-review: escalations were approved by Codex, with no approval card
and no notification.

The reviewer belongs to the Paseo mode, so the preset now always names
it and the request always carries it. An agent with no Paseo mode still
inherits the Codex configuration, unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: HMWCS <121323913+HMWCS@users.noreply.github.com>
2026-09-23 05:46:12 +02:00
paseo-bot[bot] 5303313632 Keep an archived worktree restorable after a failed re-import (#5238)
* fix(server): keep a workspace's placement when its directory is gone

Resolving a workspace read the checkout at its cwd and reconciled the
recorded placement against it. A git read answers "not a checkout" for a
plain directory and for a directory that is not there, so an archived
worktree whose directory had been removed came back as kind "directory"
with branch, worktreeRoot and mainRepoRoot null.

That rewrite happens before the import operation runs, and the failure
path only rolls back a workspace the import created, so a failed
re-import left the record erased. Workspace recovery can only recreate a
missing directory while the record still says "worktree" with a branch,
so the archived worktree stopped being restorable from Paseo and
reported workspace_directory_missing instead.

Placement is now reconciled only against a directory that is there. The
absence is resolved once, where the workspace directory is read, and
reconcileWorkspacePlacement refuses to rewrite from it, so the active
refresh path is covered by the same rule. The workspace reconciliation
pass already skipped missing directories for this reason.

Closes #5111

* fix(server): treat a directory lost mid-read as an absence

A worktree removed or unmounted while the git read is in flight makes
that read report "not a checkout", which erased the placement the same
way a directory that was already gone did. The read now counts as
evidence only while the directory is there on both sides of it.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 05:46:08 +02:00
paseo-bot[bot]andpaseo-bot[bot] 81ad00790f Let Stop settle an agent whose Pi or OMP runtime has exited (#5235)
* Let Stop settle an agent whose Pi or OMP runtime has exited

AgentSession.interrupt() is documented to reject "only when foreground
ownership is still uncertain", and to be a successful no-op when the
session is already idle. The Pi and OMP sessions broke that: interrupt()
issues `abort` (and `clear_queue` for Pi) over the JSONL RPC transport,
and once the child process is gone every request rejects with
"<name> process is closed".

AgentManager reads that rejection as "the provider still owns the turn",
returns `refused`, and leaves the tracked run in place. The user gets
"Cannot stop agent <id> because its active run cancellation was not
acknowledged", the agent stays pinned at `running`, and pressing Stop
again repeats it forever — only a daemon restart clears it.

A dead runtime owns nothing: no turn is running and no prompt is queued,
which is exactly the postcondition abort and clear_queue promise. Route
both through JsonlRpcProcess.requestStopWork, which resolves when the
process is gone and still surfaces real failures from a live process.
The check covers the process dying while the request is in flight, so a
race cannot reintroduce the wedge.

* Require an observed child exit before treating stop work as done

`disposed` marks the transport finished, not the child gone: `close()`
disposes before termination completes, a spawn error disposes without
touching the child, and a child can outlive SIGKILL. Keying
requestStopWork on it could report a turn canceled while the child was
still working.

Track the child's exit event instead, and resolve only on that. A
disposed transport whose child has not exited still rejects, so the
caller keeps refusing a cancellation it cannot prove.

Also move the in-flight tests' command detection into named
`exitOnCommand` helpers, and reuse the extracted `onOmpCommand` for
`replyToCommands` so the OMP file matches the Pi one.

* Let a stop survive an stdin failure that precedes the child's exit

A dying child breaks its stdin pipe a fraction before it emits `exit`.
The broken pipe fails the in-flight abort and starts a termination, and
deciding at that moment saw `exited` still false, so the stop was
refused for a runtime that was already on its way out. That is the
reported wedge again, reached through a narrower race.

close() now records the termination it starts, and requestStopWork waits
for it before deciding. A child that goes on to exit resolves the stop; a
child that outlives SIGKILL still rejects it.

---------

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-23 04:45:08 +02:00
paseo-bot[bot] 290306fd16 Read an archived agent's logs after its worktree is removed (#5229)
* Read an archived agent's history without its working directory

Loading an archived agent for history rejected when its working
directory no longer existed, so `paseo logs <archived-agent>` and
`get_agent_activity` failed with "Working directory does not exist"
once the workspace archive removed the worktree the agent ran in.

Resuming a persisted session already distinguishes an interactive
resume from a history load, but residency was settled after the
session config was prepared, so the directory check ran for both.
Settle residency first and require the directory only for a session
that will actually run in it.

* Assert the archived transcript is readable, not just loadable

* Let archived timeline reads succeed without resurrecting the workspace

The auto-archive test asserted that history reads for an archived agent
in a removed worktree are rejected. That was the old behavior, not the
invariant its comment describes. Assert the invariant instead: the
reads succeed, the removed worktree stays removed, no workspace
observation comes back, and the next agent lifecycle is unaffected.

* Archive the agent only after its turn is finalized

The regression test dispatched a run and archived immediately. Dispatch
resolves before the provider appends the reply to its history, so the
transcript the test reads back could be written after the archive, and
the read could find nothing.

Wait for the turn to be finalized first. The provider appends the
assistant message before it emits turn_completed, so once the turn is
finalized the transcript is already on disk.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 02:45:28 +02:00
paseo-bot[bot] 3a36cc33c6 Keep workspaces on a disk or share that is not mounted (#5227)
* fix(server): keep workspaces whose project root is unreachable

Reconciliation archived every active workspace whose cwd returned ENOENT on
a single stat, including the pass that runs at daemon startup. An external
disk that has not mounted yet, or a network share that is briefly down, made
every workspace on it disappear from the sidebar along with its sessions, and
nothing brought them back when the directory returned.

A missing path is only evidence that a workspace was removed when the project
it belongs to is still readable. When the project root is missing too, the
whole location is unavailable, and projects already persist through that.
Workspaces now follow the same rule.

Closes #4982

* fix(server): read project roots after workspace directories

Reading the project roots first left a window where a volume could go away
between the two reads, marking a project reachable while its workspace looked
missing. Reading them after inverts the skew: a volume that disappears mid-pass
makes the project unreachable, which withholds an archive instead of causing
one.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 00:45:12 +02:00
paseo-bot[bot] 17c5050216 Show a fork checkout's pull request in the workspace (#5221)
* Resolve a fork checkout's pull request by its head repository

A fork checkout's pull request lives in the parent repository, and its
head repository is the only thing that tells it apart from every other
fork's pull request on the same branch name. The per-checkout lookup
asked `gh pr list --repo <parent> --head <forkOwner>:<branch>`, which
`gh` cannot answer: it forwards the whole value as a head branch name,
so the query always came back empty. Every synchronous refresh then
overwrote the status the background poll had resolved, and the workspace
showed no pull request.

Fork checkouts now resolve through the REST pulls endpoint, the one
lookup that takes a head repository, and read each resulting pull
request through the ordinary view path. That also removes the
disambiguation the bare branch name would have needed: `--head main`
already returns thirty pull requests upstream, far past the ten-item
candidate page, so a fork checkout on a common branch name would still
have lost its own pull request.

The three existing fork tests asserted the unanswerable invocation while
their fake runner answered it regardless of the head value. They now run
against a runner that matches head refs the way `gh` does.

* Let a failed fork lookup surface, and keep the checked-out attempt

Two corrections to the fork pull request lookup.

An operational failure on the REST lookup, a rate limit, a timeout, a
DNS failure, was caught and returned as an empty list, so a refresh
reported "no pull request" for a checkout that has one. The lookup now
only handles what it can act on and lets the rest reach the caller, the
same way the non-fork list path does.

A fork branch reused for several attempts can carry the checkout's head
commit on an older pull request than the three the lookup reads. The
page is now requested in full and the attempts carrying the checked-out
commit are read first, so the bound on full reads no longer decides
which attempt the workspace can see.

* Use a function declaration for the head-commit predicate

* Use a function declaration for the test pull request helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-22 23:44:49 +02:00
morvenandMohamed Boudra 5db3bc82fd fix(app): reattach workspace labels when a connection is restored (#5079)
* fix(app): reattach workspace labels when a connection is restored

connectionChanged() drops the workspace label replica on every connection
change but never puts it back: the only caller of connectWorkspaceLabels()
is refreshAll(), which is one of the two branches of requestDemandRefresh().
Both branches mark the connection epoch as satisfied, so when route-only
demand refreshes first, the later full-directory demand early-returns and the
label channel stays detached for the whole connection.

The picker then renders rows merged from other online hosts, every row
disabled and no explanation, since the offline case has no MenuHint.

Attach the replica where the subscriptions are restored, so it no longer
depends on which demand branch wins the epoch.

* fix(app): keep the label replica attached across refreshes

Reattaching on every online connection meant refreshAll() could reconnect a
replica that was already attached, and connect() begins by disconnecting,
which publishes an offline status. The picker disables every row while a host
is offline, so a plain refresh made the menu flicker disabled.

Reuse the live connection when the same client is already attached and just
refresh the catalog. Cover both the flicker and the reconnect path, which is
the case the fix is named after.

* test(app): verify workspace labels after route refresh

The route-first regression must expose the restored label catalog used by sidebar chips, and the test client must accept the directory query passed by the public observer.

* fix(app): show label catalog load errors in picker

An attachment failure reaches the label host snapshot, but the picker only showed mutation errors. Surface the host error beside the disabled or empty label list so the failure is visible in the affected workspace.

* test(app): keep label attachment coverage in directory suite

The directory fake already owns the connection and demand setup. Reuse it for the route-first, full-demand, refresh, and reconnect cases so this regression follows the existing suite and cleanup.

* fix(app): localize label catalog load errors in picker

The host snapshot retains diagnostic details, while the workspace picker gives users the translated catalog-load message.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-22 23:38:50 +02:00
Mohamed Boudra 3dbbbb535e Sponsor page rework with paid homepage sponsor spots (#5219)
* feat(website): rework sponsorship with homepage sponsor spots

The founder note now states how the project is funded (sponsorship and
Paseo Hub) instead of describing the product, and the same note and
backing links render on the homepage and /sponsor from one module so the
two cannot drift.

Companies can sponsor one of four homepage logo spots at $500 a month
through Stripe rather than GitHub Sponsors, because they need invoices
and a vendor relationship. The spots render as placeholders until sold;
approving a sponsor is an entry in src/data/sponsors.ts. The checkout
URL is a placeholder until the Stripe Payment Link exists.

* feat(website): point sponsor spot checkout at the Stripe payment link

* docs(readme): add a sponsors section

* docs(readme): add the sponsors section to the translated READMEs

* docs(website): document the sponsor approval and Stripe cap workflow
2026-09-22 22:54:11 +02:00
a3fc59e818 Keep a project in the sidebar when its folder name ends in a space (#5205)
* fix(app): keep sidebar order keys that end in whitespace

A project view key embeds the project's path, so a directory whose name
ends in a space produces a key that ends in a space. The sidebar order
store trimmed every key on write, so the stored key could never match
the key the sidebar looks up. computeSidebarOrderUpdates saw the project
as missing from the order on every pass, appended it, and the store
trimmed it away again, setting a fresh array each time. The effect that
applies those updates re-ran on every render until React gave up with
error #185, and the app failed to load until the folder was renamed.

Keep keys verbatim on the runtime setters, dropping only blank keys and
duplicates. Migration of persisted state still trims, where a stray
space is an artifact of an older format rather than part of the key.

Fixes #4880

* test(app): give AsyncStorage a real in-memory stub instead of mocking it

`docs/testing.md` bans `vi.mock`, and the test added here mocked
`@react-native-async-storage/async-storage` only because the module has no
Node implementation: its default export reaches for the native module, so
every write from the persist middleware rejects and a test that touches a
persisted store drowns in unhandled rejections.

That is exactly the case `test-stubs/` exists for, reached through the
resolve.alias in `vitest.config.ts` the same way react-native-unistyles,
react-native-svg and expo-linking are. The stub is a real in-memory
key-value store with the same contract, so a test can read back what a
store wrote rather than inspect a recording double.

`sidebar-view-store.test.ts` carried the same mock for the same reason and
no longer needs it either.

* test(app): give the AsyncStorage stub the multi-key calls the app uses

`provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
is handed the real AsyncStorage, so it calls `multiGet` and `multiRemove`
during reconciliation and cleanup. The stub stopped at the single-key calls,
which is fine until a test reaches the provider cache and then fails with
`storage.multiGet is not a function`.

`multiSet` comes along with them so the three multi-key calls behave as a set
rather than leaving the next caller to notice the gap the same way.

* test(app): cover a project folder name ending in a space end to end

Adds a browser regression for #4880: the daemon accepts a directory whose
name ends in a space, and the sidebar has to render its project row instead
of tearing the app down with "Maximum update depth exceeded" on every load.

* test(app): move the trailing-space spec setup behind a seeded-project helper

docs/testing.md asks for E2E bodies that read like plain English with the
setup in helpers, and for helpers that own the project record until cleanup.
The spec did its directory creation, daemon seeding, and removal inline.

seedProjectNamedWithTrailingSpace owns the directory and the daemon project
and returns a cleanup, the same shape seedWorkspace returns, which cannot be
used here because mkdtemp appends the random suffix and so cannot produce a
name that ends in a space. The body is now goto, assert the row, assert no
error screen.

The row is still located by its sidebar-project-row test id, which is how
every other sidebar spec and the shared sidebar helpers address a project row.

---------

Co-authored-by: L4XB <lukas.buck@e-mail.de>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-22 22:06:24 +02:00
paseo-ai[bot] bc5bc969c8 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 18:44:27 +00:00
Mohamed Boudra 8186585201 chore(release): cut 0.9.1 2026-09-22 20:34:13 +02:00
Mohamed Boudra eb665a55a8 docs(changelog): add 0.9.1 entry 2026-09-22 20:30:04 +02:00
Mohamed Boudra d22737c77a chore: keep heap snapshots out of git
The daemon dumps a heap snapshot next to the server package when it nears
its heap limit. Those files are multi-gigabyte debugging artifacts we want
to keep on disk, not stage.
2026-09-22 20:30:04 +02:00
aeb98f813a feat(claude): add Opus 5.5 with its model capabilities (#5200)
feat(claude): add Opus 5.5 with its model capabilities

Combine the catalog and runtime identity work from #5191 with the
per-model effort default from #5197. Claude Code 2.1.280 introduces
Opus 5.5 with 1M context, medium effort, and always-on thinking.
Resolve prefixed minor model releases before their major version so
Opus 5.5 retains its identity and capabilities after a turn.

Co-authored-by: Leonardo G. Urci <leonardoguimaraesurci@gmail.com>
Co-authored-by: Sebastian Galindo <seb@jexhq.com>
Co-authored-by: Rodrigo <rodrigo.parisusao25201@gmail.com>
2026-09-22 20:18:00 +02:00
Mohamed BoudraandBag Raul Cristian c8a7667a03 fix(app): keep conversations in the sidebar across a sequenced catch-up (#5189)
When the client reconnects with a live sync cursor, the daemon answers
with only the agents that changed, and the replica rebuilds the merged
directory from what it already holds. That rebuild went through
projectAgentDirectoryEntry, which returned null for any agent without a
projectPlacement, so those members never reached the merged map,
commitSnapshot treated them as absent, and removeFromDirectory deleted
them from the store.

An agent hydrated from its timeline or restored from the replica cache
has no placement: neither normalizeAgentSnapshot nor deserializeAgent
sets one. Every other path in the same flow derives a placement from the
working directory instead of dropping the agent, so the projection does
the same now via resolveProjectPlacement, and the function is total.

The conversations were always intact on the daemon; only the client lost
them, which is why a full restart brought them back until the next
reconnect drained the sidebar again.

Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com>
2026-09-22 18:56:05 +02:00
4c051388e1 Reach every Codex conversation in Import session (#5174)
* fix(codex): page through Codex threads when listing importable sessions

Codex answers thread/list with at most 100 rows per response whatever
limit is requested, and returns a cursor for the rest. The adapter read
the first response and returned, so Import session could only ever offer
the newest 100 Codex threads: on a store of 456 threads, a host-wide
search that asks for a 500-thread scan saw 100.

Follow nextCursor until the caller's window is full, and ask Codex to
page by last use so the scanned window matches how the import sheet ranks
candidates. Unknown keys are ignored by Codex, so older builds keep their
creation-time order.

Closes #5073

Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>

* fix(codex): end the thread scan on a page that carries no threads

The previous guard only caught a cursor that repeated immediately, so a
Codex cursor cycle across two or more pages (A to B to A) kept paging
for ever behind a caller that had already timed out.

Every page that continues the scan now has to carry rows, which bounds
the loop by the requested window whatever cursors Codex hands back.

* fix(codex): keep a thread once when the scan sees it on two pages

Paging by last use means a thread touched while the scan is running
sorts to the front and comes back on a later page, so the same
conversation could be offered twice and displace another one inside the
window.

Identify each thread as it arrives and keep it once. A page that brings
nothing new now ends the scan, which also covers a cursor that cycles
while still serving rows.

---------

Co-authored-by: paseo-bot <paseo-bot@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-09-22 17:20:31 +02:00
paseo-ai[bot] 91d9cf1dbd fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 10:36:31 +00:00
86 changed files with 2819 additions and 494 deletions
+3
View File
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 0.9.1 - 2026-09-22
### Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
### Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
## 0.9.0 - 2026-09-22
### Added
+6
View File
@@ -151,6 +151,12 @@ npm run build:server
npm run typecheck
```
## スポンサー
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援するか、月 500 ドルで[スポンサー枠を購入](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)して、貴社のロゴをここと [paseo.sh のホームページ](https://paseo.sh/sponsor#spot)に掲載できます。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 関連プロジェクト
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
+6
View File
@@ -161,6 +161,12 @@ npm run build:server
npm run typecheck
```
## 스폰서
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원하거나, 월 500달러에 [스폰서 자리를 구매](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)하여 회사 로고를 이곳과 [paseo.sh 홈페이지](https://paseo.sh/sponsor#spot)에 게재할 수 있습니다.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 관련 프로젝트
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
+6
View File
@@ -193,6 +193,12 @@ npm run build:server
npm run typecheck
```
## Sponsors
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra), or [sponsor a spot](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00) for $500 a month to put your company's logo here and on the [paseo.sh homepage](https://paseo.sh/sponsor#spot).
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## Related projects
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
+6
View File
@@ -151,6 +151,12 @@ npm run build:server
npm run typecheck
```
## 赞助
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作,或者以每月 500 美元[赞助一个展示位](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00),把贵公司的 logo 放在这里和 [paseo.sh 首页](https://paseo.sh/sponsor#spot)上。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 相关项目
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-6du3a0DzuvF+m2cPTOXDhdZZScQPkqicikdDxWIKZ5o=
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.9.0",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.0",
"version": "0.9.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36196,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37262,12 +37262,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/server": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37514,10 +37514,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37528,7 +37528,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37772,7 +37772,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.0",
"version": "0.9.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38668,7 +38668,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38697,14 +38697,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38714,8 +38714,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38737,7 +38737,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38764,7 +38764,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38982,16 +38982,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.0",
"@getpaseo/highlight": "0.9.0",
"@getpaseo/plugin": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39895,7 +39895,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,77 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { connectSeedClient } from "../support/helpers/seed-client";
interface SeededProject {
projectKey: string;
cleanup(): Promise<void>;
}
/**
* Adds a project whose directory name ends in a space, which `seedWorkspace`
* cannot produce because `mkdtemp` appends the random suffix. The project is
* owned until `cleanup`, which removes the daemon record before the directory.
*/
async function seedProjectNamedWithTrailingSpace(): Promise<SeededProject> {
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-trailing-space-"));
const directoryPath = path.join(parentDirectory, `Reklamation-${randomUUID().slice(0, 8)} `);
await mkdir(directoryPath);
await writeFile(path.join(directoryPath, "README.md"), "# Trailing space\n");
const client = await connectSeedClient();
const removeParentDirectory = () => rm(parentDirectory, { recursive: true, force: true });
try {
const added = await client.addProject(directoryPath);
expect(added.error).toBeNull();
expect(added.project?.projectRootPath).toBe(directoryPath);
const project = added.project;
if (!project?.projectKey) {
throw new Error(`The daemon added ${directoryPath} without a project key`);
}
return {
projectKey: project.projectKey,
cleanup: async () => {
await client.removeProject(project.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await removeParentDirectory().catch(() => undefined);
},
};
} catch (error) {
await client.close().catch(() => undefined);
await removeParentDirectory().catch(() => undefined);
throw error;
}
}
async function expectProjectRow(page: Page, projectKey: string): Promise<void> {
const row = page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
await expect(row).toBeVisible({ timeout: 60_000 });
}
async function expectAppShellRendered(page: Page): Promise<void> {
await expect(page.getByText("Paseo ran into a problem.")).toHaveCount(0);
}
// A project view key carries the project's path, so a directory whose name ends
// in a space produces a key that ends in a space. The sidebar order store used
// to trim it on write, which left the reconcile effect writing a new order on
// every render until React tore the app down with "Maximum update depth
// exceeded" on every load (#4880).
test("a project whose folder name ends in a space renders in the sidebar", async ({ page }) => {
const project = await seedProjectNamedWithTrailingSpace();
try {
await gotoAppShell(page);
await expectProjectRow(page, project.projectKey);
await expectAppShellRendered(page);
} finally {
await project.cleanup();
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -0,0 +1,16 @@
import { BackHandler, Platform } from "react-native";
import type { BackPressSource } from "./visibility-tracker";
/**
* Android's hardware Back press.
*
* iOS and web have no such press, and `react-native-web`'s `BackHandler` logs an error as soon as
* anything subscribes, so nothing is registered off Android.
*/
export const systemBackPress: BackPressSource = {
subscribe(onBackPress) {
if (Platform.OS !== "android") return () => {};
const subscription = BackHandler.addEventListener("hardwareBackPress", onBackPress);
return () => subscription.remove();
},
};
@@ -5,6 +5,7 @@ import {
import React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ElementRef, ReactNode } from "react";
import { systemBackPress } from "./back-press";
import {
type BottomSheetController,
createBottomSheetVisibilityTracker,
@@ -85,7 +86,11 @@ export function useIsolatedBottomSheetVisibility({
onCloseRef.current = onClose;
const tracker = useMemo(
() => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }),
() =>
createBottomSheetVisibilityTracker({
onClose: () => onCloseRef.current(),
backPress: systemBackPress,
}),
[],
);
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
type BackPressSource,
type BottomSheetController,
createBottomSheetVisibilityTracker,
} from "./visibility-tracker";
@@ -20,21 +21,55 @@ class FakeBottomSheet implements BottomSheetController {
}
}
/**
* Stands in for Android's `BackHandler`, which offers the press to listeners newest-first and
* stops at the first one that claims it.
*/
class FakeBackPress implements BackPressSource {
private listeners: (() => boolean)[] = [];
subscribe(onBackPress: () => boolean): () => void {
this.listeners.push(onBackPress);
return () => {
const index = this.listeners.indexOf(onBackPress);
if (index >= 0) this.listeners.splice(index, 1);
};
}
/** True when a sheet claimed the press, false when it falls through to the navigator. */
press(): boolean {
for (let index = this.listeners.length - 1; index >= 0; index--) {
if (this.listeners[index]?.()) return true;
}
return false;
}
}
function setup() {
const sheet = new FakeBottomSheet();
const backPress = new FakeBackPress();
let closeCount = 0;
const tracker = createBottomSheetVisibilityTracker({
onClose: () => {
closeCount += 1;
},
backPress,
});
return {
sheet,
backPress,
tracker,
closeCount: () => closeCount,
};
}
/** Opens a sheet and runs it through the presentation the real sheet reports back. */
function openSheet(context: ReturnType<typeof setup>) {
context.tracker.attachController(context.sheet);
context.tracker.syncDesired({ visible: true });
context.tracker.handleSheetIndexChange(0);
}
describe("bottom sheet visibility tracker", () => {
it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => {
const { sheet, tracker } = setup();
@@ -160,4 +195,53 @@ describe("bottom sheet visibility tracker", () => {
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
});
it("dismisses the sheet on a Back press instead of letting it reach the navigator", () => {
const context = setup();
openSheet(context);
expect(context.backPress.press()).toBe(true);
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
});
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
const context = setup();
context.tracker.attachController(context.sheet);
context.tracker.syncDesired({ visible: false });
expect(context.backPress.press()).toBe(false);
openSheet(context);
context.tracker.handleSheetIndexChange(-1);
context.tracker.handleSheetDismiss();
expect(context.backPress.press()).toBe(false);
});
it("gives the Back press to the sheet presented last", () => {
const backPress = new FakeBackPress();
const sheets = ["below", "above"].map((name) => {
const sheet = new FakeBottomSheet();
const tracker = createBottomSheetVisibilityTracker({ onClose: () => {}, backPress });
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(0);
return { name, sheet };
});
backPress.press();
expect(sheets.map((entry) => [entry.name, entry.sheet.events.at(-1)?.type])).toEqual([
["below", "present"],
["above", "dismiss"],
]);
});
it("stops claiming Back once the sheet leaves the tree", () => {
const context = setup();
openSheet(context);
context.tracker.attachController(null);
expect(context.backPress.press()).toBe(false);
});
});
@@ -8,6 +8,22 @@ export interface BottomSheetVisibilityInput {
isEnabled?: boolean;
}
/**
* The system Back press, as the tracker needs it.
*
* A sheet is presented through `@gorhom/portal`, which renders it into the app's own view tree
* rather than a native modal window, so Android delivers Back to the navigator underneath it
* unless something on top claims the press first.
*
* `subscribe` registers a listener and returns its removal. The listener returns `true` when it
* claims the press. Listeners are offered the press newest-first, so a tracker holds its
* subscription only while its sheet is on screen, and a stacked sheet answers before the one
* below it.
*/
export interface BackPressSource {
subscribe(onBackPress: () => boolean): () => void;
}
export interface BottomSheetVisibilityTracker {
attachController(controller: BottomSheetController | null): void;
syncDesired(input: BottomSheetVisibilityInput): void;
@@ -19,23 +35,46 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
export function createBottomSheetVisibilityTracker(opts: {
onClose: () => void;
backPress?: BackPressSource;
}): BottomSheetVisibilityTracker {
let controller: BottomSheetController | null = null;
let visible = false;
let isEnabled: boolean | undefined;
let phase: BottomSheetPhase = "closed";
let hasNotifiedClose = false;
let releaseBackPress: (() => void) | null = null;
function setPhase(next: BottomSheetPhase): void {
phase = next;
syncBackPress();
}
/** Holds the Back press for exactly the window the sheet is on screen, and no longer. */
function syncBackPress(): void {
const wantsBackPress = phase === "presented" && controller !== null;
if (wantsBackPress === (releaseBackPress !== null)) return;
if (!wantsBackPress) {
releaseBackPress?.();
releaseBackPress = null;
return;
}
releaseBackPress =
opts.backPress?.subscribe(() => {
dismiss();
return true;
}) ?? null;
}
function present(): void {
if (!controller || phase !== "closed") return;
phase = "presenting";
hasNotifiedClose = false;
setPhase("presenting");
controller.present();
}
function dismiss(): void {
if (!controller || phase === "closed" || phase === "dismissing") return;
phase = "dismissing";
setPhase("dismissing");
controller.dismiss();
}
@@ -48,6 +87,9 @@ export function createBottomSheetVisibilityTracker(opts: {
return {
attachController(next) {
controller = next;
// A sheet that left the tree cannot answer, so it lets go of the press without changing
// phase: the parent has not acknowledged the dismissal yet.
syncBackPress();
if (next && visible && isEnabled !== false) {
present();
}
@@ -61,7 +103,7 @@ export function createBottomSheetVisibilityTracker(opts: {
return;
}
if (phase === "dismissing") {
phase = "closed";
setPhase("closed");
hasNotifiedClose = false;
return;
}
@@ -70,21 +112,21 @@ export function createBottomSheetVisibilityTracker(opts: {
handleSheetIndexChange(index) {
if (index !== -1) {
if (phase === "presenting" || phase === "dismissing") {
phase = "presented";
setPhase("presented");
}
return;
}
if (phase === "presenting" || phase === "presented") {
phase = "dismissing";
setPhase("dismissing");
}
},
handleSheetDismiss() {
if (visible) {
phase = "dismissing";
setPhase("dismissing");
notifyClose();
return;
}
phase = "closed";
setPhase("closed");
hasNotifiedClose = false;
},
};
@@ -369,4 +369,25 @@ describe("AgentDirectoryReplica", () => {
).toEqual(directoryPlacement);
store.clearSession(serverId);
});
it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => {
const serverId = "agent-replica-timeline-membership";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
);
// A conversation opened from history arrives over its timeline, without the
// project placement the directory carries, and is a member from then on.
replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only"));
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
replica.commitChanges([], [], []);
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
store.clearSession(serverId);
});
});
@@ -6,15 +6,21 @@ import type { Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import { AgentStoreProjection } from "./internal/agent-store";
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null {
return agent.projectPlacement
? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement }
: null;
// A member hydrated from its timeline or restored from the replica cache carries
// no placement. Every other path in this flow derives one from the working
// directory, so this one does too; returning nothing here would drop the member
// from the merged directory and delete the conversation on the next catch-up.
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry {
return {
agent: projectAgentSnapshot(agent),
project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }),
};
}
export interface AgentLifecycleToken {
@@ -164,8 +170,7 @@ export class AgentDirectoryReplica {
const previous = this.storeProjection.snapshot();
const merged = new Map<string, FetchAgentsEntry>();
for (const agent of previous.values()) {
const entry = projectAgentDirectoryEntry(agent);
if (entry) merged.set(agent.id, entry);
merged.set(agent.id, projectAgentDirectoryEntry(agent));
}
for (const entry of entries) merged.set(entry.agent.id, entry);
const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({
@@ -6,7 +6,10 @@ import {
type SqliteValue,
} from "@/runtime/replica-cache/row-store-sqlite";
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
DaemonClient,
WorkspaceLabelListPayload,
} from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
normalizeProjectDescriptor,
@@ -15,6 +18,7 @@ import {
} from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
import { useWorkspaceLabels } from "@/workspace-labels";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import {
DirectoryRefreshSupersededError,
@@ -29,6 +33,8 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
class FakeDirectoryClient {
supportsWorkspaceLabels = false;
listWorkspaceLabelsCalls = 0;
fetchAgentsCalls = 0;
lastAgentOptions: unknown;
fetchWorkspacesCalls = 0;
@@ -144,19 +150,36 @@ class FakeDirectoryClient {
});
}
getLastServerInfoMessage(): null {
return null;
observeWorkspaceLabels() {
return subscriptionFixture(this.listWorkspaceLabels(), () => () => {});
}
async listWorkspaceLabels(): Promise<WorkspaceLabelListPayload> {
this.listWorkspaceLabelsCalls += 1;
return {
requestId: "workspace-labels",
labels: [{ name: "Urgent", color: "red" }],
sync: { mode: "snapshot", removals: [], generation: "generation-1", headSeq: 0 },
};
}
getLastServerInfoMessage() {
return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null;
}
}
const serverIds = new Set<string>();
function createDirectory(serverId: string): {
function createDirectory(
serverId: string,
options?: { workspaceLabels?: boolean },
): {
client: FakeDirectoryClient;
directory: DirectorySync;
} {
serverIds.add(serverId);
const client = new FakeDirectoryClient();
client.supportsWorkspaceLabels = options?.workspaceLabels === true;
const directory = new DirectorySync(serverId, {
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
@@ -207,6 +230,7 @@ function createAgent(serverId: string, id: string) {
afterEach(() => {
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
serverIds.clear();
useWorkspaceLabels.setState({ hosts: {} });
});
describe("DirectorySync session readiness", () => {
@@ -640,6 +664,63 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("reconciles agent changes on top of the accepted cached baseline", async () => {
const serverId = "cached-agent-changes";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "cached-agent");
const releaseNetwork = client.holdAgentFetch();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map([[cachedAgent.id, cachedAgent]]),
workspaces: new Map(),
projects: new Map(),
checkpoint: { agents: { generation: "g", afterSeq: 7 } },
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshAgents();
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
releaseNetwork({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
});
await refresh;
expect(client.lastAgentOptions).toMatchObject({
sync: { generation: "g", afterSeq: 7 },
});
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true);
directory.dispose();
});
it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => {
const serverId = "late-directory-cache";
serverIds.add(serverId);
@@ -1292,3 +1373,103 @@ it("fills every cached workspace beneath live updates received during the SQLite
await cache.flush();
database.close();
});
function createWorkspaceLabelDirectory(serverId: string) {
const { client, directory } = createDirectory(serverId, { workspaceLabels: true });
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: {
directorySync: true,
projectList: true,
workspaceLabels: true,
workspaceMultiplicity: true,
},
});
return { client, directory };
}
async function flushWorkspaceLabels(): Promise<void> {
for (let index = 0; index < 4; index += 1) await Promise.resolve();
}
describe("DirectorySync workspace labels", () => {
it("loads the catalog when an agent route is open before the sidebar", async () => {
const serverId = "workspace-labels-route-then-full";
const { directory } = createWorkspaceLabelDirectory(serverId);
directory.setAgentRouteDemand(["agent-1"]);
await directory.refreshDemand();
directory.setDemand({}, true);
await flushWorkspaceLabels();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
it("loads the catalog when the sidebar demands the directory first", async () => {
const serverId = "workspace-labels-full-first";
const { directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
it("keeps the picker online during a full refresh", async () => {
const serverId = "workspace-labels-refresh-no-flicker";
const { client, directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
const callsBeforeRefresh = client.listWorkspaceLabelsCalls;
const statuses: string[] = [];
const unsubscribe = useWorkspaceLabels.subscribe((state) => {
const status = state.hosts[serverId]?.status;
if (status) statuses.push(status);
});
await directory.refreshDemand();
expect(client.listWorkspaceLabelsCalls).toBeGreaterThan(callsBeforeRefresh);
expect(statuses).not.toContain("offline");
unsubscribe();
directory.dispose();
});
it("restores the catalog after reconnecting", async () => {
const serverId = "workspace-labels-reconnect";
const { client, directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
directory.connectionChanged({
client: null,
status: "offline",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
expect(useWorkspaceLabels.getState().hosts[serverId]?.status).toBe("offline");
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 2 },
});
await flushWorkspaceLabels();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
});
@@ -189,6 +189,8 @@ export class DirectorySync {
this.connection = connection;
this.abortPendingSessionWaits();
if (!connection.client || connection.status !== "online") return true;
// Reattach labels here because route-only demand can satisfy the epoch before full demand requests them.
void this.connectWorkspaceLabels().catch(() => undefined);
if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined);
return true;
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { migrateSidebarOrderState } from "./sidebar-order-store";
import { beforeEach, describe, expect, it } from "vitest";
import {
computeSidebarOrderUpdates,
type SidebarProjectEntry,
} from "@/hooks/sidebar-workspaces-view-model";
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
describe("migrateSidebarOrderState", () => {
it("prefixes legacy per-server workspace order with the source server id", () => {
@@ -31,3 +35,71 @@ describe("migrateSidebarOrderState", () => {
expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]);
});
});
// A directory whose name ends in a space produces a view key that ends in a
// space. Trimming it on write used to make the sidebar's reconcile effect loop
// forever, crashing the app with React error #185 (see #4880).
describe("sidebar order keys that end in whitespace", () => {
const PROJECT_KEY = "host:srv-1:/home/u/Reklamation ";
beforeEach(() => {
useSidebarOrderStore.setState({
projectOrder: [],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: {},
});
});
it("stores a project key exactly as given", () => {
useSidebarOrderStore.getState().setProjectOrder([PROJECT_KEY]);
expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]);
});
it("scopes a workspace order under the project key the sidebar indexes by", () => {
useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]);
// use-sidebar-workspaces-list reads workspaceOrderByProject by view key
// directly, so the scope has to be stored under that same key.
expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([
"srv-1:main",
]);
expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]);
});
it("settles the sidebar order reconcile instead of rewriting it forever", () => {
const projects: SidebarProjectEntry[] = [
{
viewKey: PROJECT_KEY,
projectName: "Reklamation ",
projectKind: "non_git",
iconWorkingDir: "/home/u/Reklamation ",
hosts: [],
workspaces: [],
},
];
const runPass = () =>
computeSidebarOrderUpdates({
projects,
persistedProjectOrder: useSidebarOrderStore.getState().projectOrder,
getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key),
});
// First pass adds the newly visible project.
const first = runPass();
expect(first.projectOrder).toEqual([PROJECT_KEY]);
useSidebarOrderStore.getState().setProjectOrder(first.projectOrder ?? []);
// Second pass must find nothing left to do; otherwise the effect that
// applies these updates re-runs on every render.
expect(runPass().projectOrder).toBeNull();
});
it("still trims keys when migrating persisted state", () => {
const migrated = migrateSidebarOrderState({
projectOrder: [" host-a:one ", "host-a:one"],
});
expect(migrated.projectOrder).toEqual(["host-a:one"]);
});
});
+30 -11
View File
@@ -38,6 +38,8 @@ interface SidebarWorkspaceOrderScope {
projectViewKey: string;
}
// Trims each key. Only for persisted state read at migration time, where a
// stray space is an artifact of an older format rather than part of the key.
function normalizeKeys(keys: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
@@ -54,6 +56,28 @@ function normalizeKeys(keys: string[]): string[] {
return normalized;
}
/**
* Drops blank keys and duplicates but keeps each key exactly as given. View
* keys embed a project's path, so a directory whose name ends in a space
* produces a key that ends in a space. Trimming it stores a key that can never
* match the one the sidebar looks up, so the caller sees its key as missing,
* writes it again, and the effect that reconciles the order never settles.
*/
function dedupeKeys(keys: string[]): string[] {
const seen = new Set<string>();
const deduped: string[] = [];
for (const key of keys) {
if (!key.trim() || seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(key);
}
return deduped;
}
function normalizeWorkspaceOrderByProject(
workspaceOrderByProject: Record<string, string[]> | undefined,
): Record<string, string[]> {
@@ -134,27 +158,22 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
workspaceOrderByProject: {},
getProjectOrder: () => get().projectOrder,
setProjectOrder: (keys) => {
const normalized = normalizeKeys(keys);
set({ projectOrder: normalized });
set({ projectOrder: dedupeKeys(keys) });
},
getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder,
setPinnedWorkspaceOrder: (keys) => {
const normalized = normalizeKeys(keys);
set({ pinnedWorkspaceOrder: normalized });
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
},
getWorkspaceOrder: (projectViewKey) => {
const scope = projectViewKey.trim();
if (!scope) return [];
return get().workspaceOrderByProject[scope] ?? [];
if (!projectViewKey.trim()) return [];
return get().workspaceOrderByProject[projectViewKey] ?? [];
},
setWorkspaceOrder: (projectViewKey, keys) => {
const scope = projectViewKey.trim();
if (!scope) return;
const normalized = normalizeKeys(keys);
if (!projectViewKey.trim()) return;
set((state) => ({
workspaceOrderByProject: {
...state.workspaceOrderByProject,
[scope]: normalized,
[projectViewKey]: dedupeKeys(keys),
},
}));
},
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import {
createSidebarViewStorage,
@@ -8,14 +8,6 @@ import {
useSidebarViewStore,
} from "./sidebar-view-store";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
}));
interface MemoryStorage extends StateStorage<Promise<void>> {
reads: string[];
}
@@ -147,6 +147,11 @@ class WorkspaceLabelsController {
client: DaemonClient;
supportsWorkspaceLabels: boolean;
}): Promise<void> {
const existing = this.connections.get(input.serverId);
if (existing?.client === input.client && input.supportsWorkspaceLabels) {
await this.refresh(input.serverId);
return;
}
this.disconnect(input.serverId);
const replica = this.replicas.get(input.serverId) ?? new HostWorkspaceLabelReplica();
this.replicas.set(input.serverId, replica);
+2 -3
View File
@@ -131,6 +131,7 @@ function WorkspaceLabelPickerPage({
}, [assignedLabels, host?.status, labels, model]);
const snapshot = useSyncExternalStore(model.subscribe, model.snapshot, model.snapshot);
const offline = !snapshot.online;
const error = snapshot.error ?? (host?.error ? t("workspaceLabels.errors.load") : null);
const pending = useMemo(() => new Set(snapshot.pendingNames), [snapshot.pendingNames]);
const toggle = useCallback(
(label: WorkspaceLabelDefinition, assigned: boolean) => {
@@ -159,9 +160,7 @@ function WorkspaceLabelPickerPage({
>
{t("workspaceLabels.create")}
</MenuSubTrigger>
{snapshot.error ? (
<MenuHint testID="workspace-label-picker-error">{snapshot.error}</MenuHint>
) : null}
{error ? <MenuHint testID="workspace-label-picker-error">{error}</MenuHint> : null}
{host?.status === "unsupported" ? (
<MenuHint>{t("workspaceLabels.updateHostUse")}</MenuHint>
) : null}
+45
View File
@@ -0,0 +1,45 @@
/**
* AsyncStorage has no implementation under Node: its default export reaches for
* the native module and every call rejects, so any test that touches a
* persisted store emits unhandled rejections from the persist middleware.
*
* This is a real in-memory key-value store with the same contract, not a
* recording double, so a test can read back what a store wrote.
*/
const entries = new Map<string, string>();
export function clearAsyncStorageStub(): void {
entries.clear();
}
const AsyncStorageStub = {
getItem: (key: string): Promise<string | null> => Promise.resolve(entries.get(key) ?? null),
setItem: (key: string, value: string): Promise<void> => {
entries.set(key, value);
return Promise.resolve();
},
removeItem: (key: string): Promise<void> => {
entries.delete(key);
return Promise.resolve();
},
getAllKeys: (): Promise<string[]> => Promise.resolve([...entries.keys()]),
// `provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
// hands it the real AsyncStorage, so a stub that stops at the single-key calls
// fails any test that reaches the provider cache with `multiGet is not a function`.
multiGet: (keys: readonly string[]): Promise<Array<[string, string | null]>> =>
Promise.resolve(keys.map((key) => [key, entries.get(key) ?? null] as [string, string | null])),
multiSet: (pairs: ReadonlyArray<readonly [string, string]>): Promise<void> => {
for (const [key, value] of pairs) entries.set(key, value);
return Promise.resolve();
},
multiRemove: (keys: readonly string[]): Promise<void> => {
for (const key of keys) entries.delete(key);
return Promise.resolve();
},
clear: (): Promise<void> => {
entries.clear();
return Promise.resolve();
},
};
export default AsyncStorageStub;
+6
View File
@@ -185,6 +185,12 @@ export default defineConfig({
find: /^expo-linking$/,
replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"),
},
// No Node implementation: every call on the real module rejects, so a test
// touching a persisted store drowns in unhandled rejections.
{
find: /^@react-native-async-storage\/async-storage$/,
replacement: path.resolve(__dirname, "test-stubs/async-storage.ts"),
},
{
find: /^lucide-react-native$/,
replacement: path.resolve(__dirname, "test-stubs/lucide-react-native.ts"),
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/server": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+6 -1
View File
@@ -52,10 +52,15 @@ interface ProviderDiagnostic {
}
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-opus-5-5",
model: "Opus 5.5",
descriptionFragment: "Latest release",
},
{
id: "claude-opus-5",
model: "Opus 5",
descriptionFragment: "Latest release",
descriptionFragment: "Previous release",
},
{
id: "claude-fable-5-1",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.0",
"version": "0.9.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.9.0",
"version": "0.9.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -65,8 +65,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -76,8 +76,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -25,4 +25,11 @@ describe("timelineItemIdentity", () => {
).toBe("review/row-1");
expect(timelineItemIdentity({ type: "reasoning", text: "hi" })).toBeNull();
});
it("identifies an assistant message by its provider message id", () => {
expect(
timelineItemIdentity({ type: "assistant_message", text: "hi", messageId: "msg-1" }),
).toBe("message:msg-1");
expect(timelineItemIdentity({ type: "assistant_message", text: "hi" })).toBeNull();
});
});
@@ -3,5 +3,8 @@ import type { AgentTimelineItem } from "./agent-types.js";
export function timelineItemIdentity(item: AgentTimelineItem): string | null {
if (item.type === "tool_call") return item.callId;
if (item.type === "plugin") return `${item.pluginId}/${item.id}`;
// Streamed chunks of one provider assistant message share its id, so they
// stay one item even when another row is recorded between two chunks.
if (item.type === "assistant_message" && item.messageId) return `message:${item.messageId}`;
return null;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -106,11 +106,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.0",
"@getpaseo/highlight": "0.9.0",
"@getpaseo/plugin": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
@@ -6,6 +6,7 @@ import { expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { startAgentRun } from "./agent-prompt.js";
import { AgentStorage } from "./agent-storage.js";
import type {
AgentClient,
@@ -140,3 +141,61 @@ test("resuming a stored agent keeps its unread flag and its last-activity time",
await rm(root, { recursive: true, force: true });
}
});
test("loads an archived agent's history after its working directory is removed", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
const worktree = path.join(root, "managed-worktree");
await mkdir(worktree, { recursive: true });
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
});
const agentId = "00000000-0000-4000-8000-000000000501";
try {
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
workspaceId: "workspace-worktree",
});
await startAgentRun(manager, agent.id, "what did you change", logger, {});
// Dispatching a run does not finish it: the provider appends the reply to its
// history afterwards, and the turn is finalized only once that append lands.
// Archive after the turn is finalized so the transcript this test reads back is
// already on disk when the worktree goes away.
const finished = await manager.waitForAgentEvent(agent.id);
expect(finished.status).toBe("idle");
await manager.archiveAgent(agent.id);
await manager.closeAgent(agent.id);
await manager.flush();
await storage.flush();
// Archiving the workspace removes the worktree it owned. The agent's history is
// persisted and reading it must not depend on that directory still being there.
await rm(worktree, { recursive: true, force: true });
const loaded = await ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(loaded.id).toBe(agentId);
// The transcript is replayed from the provider's persisted history, so the reply the
// agent gave before the worktree went away is still readable.
const replies = manager
.getTimeline(agentId)
.filter((item) => item.type === "assistant_message");
expect(replies.length).toBeGreaterThan(0);
expect(replies.every((item) => item.type === "assistant_message" && item.text.length > 0)).toBe(
true,
);
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
});
@@ -897,6 +897,74 @@ test("orders buffered pre-steer output before an immediately accepted steer", as
}
});
test("keeps one assistant message whole when a steer lands between its chunks", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-split-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
// Zero window: every streamed chunk flushes on the coalescer's leading edge,
// so the timeline reaches its next state without waiting on a timer.
agentStreamCoalesceWindowMs: 0,
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const run = manager.streamAgent(agent.id, "initial");
void (async () => {
for await (const _event of run) {
}
})();
await manager.waitForAgentRunStart(agent.id);
const conversation = () =>
manager
.fetchTimeline(agent.id, { limit: 0 })
.rows.filter(
(row) => row.item.type === "assistant_message" || row.item.type === "user_message",
)
.map((row) => row.item);
session.pushEvent({
type: "timeline",
provider: "codex",
turnId: "active-turn-1",
item: { type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
});
await expect
.poll(conversation)
.toMatchObject([
{ type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
]);
await expect(
manager.steerAgentRun(agent.id, "keep going", { clientMessageId: "keep-going-client" }),
).resolves.toEqual({ status: "accepted" });
session.pushEvent({
type: "timeline",
provider: "codex",
turnId: "active-turn-1",
item: { type: "assistant_message", text: "ted changes.", messageId: "msg-1" },
});
await expect.poll(conversation).toMatchObject([
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
{ type: "user_message", text: "keep going" },
]);
} finally {
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("orders a concurrent replacement after a pending accepted steer", async () => {
const entered = deferred<void>();
const release = deferred<void>();
@@ -27,6 +27,7 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentResumePurpose,
type AgentResumeSessionOptions,
type AgentFeature,
type AgentLaunchContext,
@@ -139,6 +140,28 @@ export type AgentRunCancellationResult =
| { status: "settled" }
| { status: "refused" };
/** A session that will run in a directory needs that directory to be there. */
async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
try {
const stats = await stat(cwd);
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: ${cwd}`);
}
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
throw new Error(`Working directory does not exist: ${cwd}`, { cause: error });
}
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to access working directory: ${cwd}`, { cause: error });
}
}
interface PreparedSessionConfig {
storedConfig: AgentSessionConfig;
launchConfig: AgentSessionConfig;
@@ -148,6 +171,8 @@ interface PreparedSessionConfig {
interface NormalizeConfigOptions {
resolveDefaultModel?: boolean;
env?: Record<string, string>;
/** Defaults to interactive. A history load reads persisted state and runs nothing. */
purpose?: AgentResumePurpose;
}
interface TimeoutOptions {
@@ -1229,7 +1254,7 @@ export class AgentManager {
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
config,
resolvedAgentId,
options?.env,
{ env: options?.env },
);
this.requireEnabledProvider(storedConfig.provider);
const client = await this.requireAvailableClient({
@@ -1331,17 +1356,21 @@ export class AgentManager {
...overrides,
provider: handle.provider,
} as AgentSessionConfig;
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
);
// Decide residency from durable state inside the lifecycle lane. A loader may
// have read the record before a queued archive or restore completed.
// have read the record before a queued archive or restore completed. Residency is
// settled before the config is prepared, because a history load reads an archived
// agent whose working directory may be gone.
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
const currentResumeOptions = record
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
: resumeOptions;
const purpose = currentResumeOptions?.purpose ?? "interactive";
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
{ purpose },
);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
if (!available) {
@@ -1358,7 +1387,7 @@ export class AgentManager {
undefined,
{
reason: "resume",
purpose: currentResumeOptions?.purpose ?? "interactive",
purpose,
workspaceId: options?.workspaceId ?? null,
},
);
@@ -4999,23 +5028,11 @@ export class AgentManager {
// Always resolve cwd to absolute path for consistent history file lookup
if (normalized.cwd) {
normalized.cwd = resolve(normalized.cwd);
try {
const cwdStats = await stat(normalized.cwd);
if (!cwdStats.isDirectory()) {
throw new Error(`Working directory is not a directory: ${normalized.cwd}`);
}
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
throw new Error(`Working directory does not exist: ${normalized.cwd}`, { cause: error });
}
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to access working directory: ${normalized.cwd}`, { cause: error });
// Only a session that will run in the directory needs it to still be there. Reading
// an archived agent's history runs nothing, and must survive the worktree it ran in
// being removed when its workspace was archived.
if (options.purpose !== "history") {
await assertUsableWorkingDirectory(normalized.cwd);
}
}
@@ -5088,9 +5105,12 @@ export class AgentManager {
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
env?: Record<string, string>,
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env });
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
env: options.env,
purpose: options.purpose,
});
const paseoToolPolicy = this.paseoToolsEnabled
? this.resolvePaseoToolPolicy(storedConfig.provider)
: { enabled: false };
@@ -643,10 +643,13 @@ export interface AgentCreateSessionOptions {
persistSession?: boolean;
}
/** What a resumed session is for: driving the agent, or reading what it already did. */
export type AgentResumePurpose = "interactive" | "history";
/** Runtime-only intent for a persisted-session resume. Never persist this option. */
export interface AgentResumeSessionOptions {
/** Defaults to interactive. History loading may be read-only for archived native sessions. */
purpose?: "interactive" | "history";
purpose?: AgentResumePurpose;
}
/**
@@ -2,6 +2,27 @@ import { describe, expect, it } from "vitest";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
describe("InMemoryAgentTimelineStore", () => {
it("keeps a streaming assistant message whole when a steering prompt is recorded mid-stream", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1");
store.append("agent-1", {
type: "assistant_message",
text: "with the uncommit",
messageId: "msg-1",
});
store.append("agent-1", { type: "user_message", text: "keep going" });
store.append("agent-1", {
type: "assistant_message",
text: "ted changes.",
messageId: "msg-1",
});
expect(store.getItems("agent-1")).toEqual([
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
{ type: "user_message", text: "keep going" },
]);
});
it("clamps an overshooting before cursor into the bounded tail window", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1", {
@@ -737,6 +737,7 @@ function createPaseoWorktreeForMcpTest(options: {
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
logger: createTestLogger(),
});
const workspaceAutoName = new WorkspaceAutoName({
@@ -17,7 +17,14 @@ import {
} from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js";
import type {
AgentPromptInput,
AgentSession,
AgentTimelineItem,
AgentStreamEvent,
} from "../../agent-sdk-types.js";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
@@ -476,7 +483,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
force: false,
});
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
expect(models.map((model) => model.id)).toContain("claude-fable-5");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
@@ -866,6 +873,97 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
async function captureSdkUserMessage(prompt: AgentPromptInput): Promise<SDKUserMessage> {
const { queryFactory, queryMock } = createQueryMock();
let resolveSent: ((message: SDKUserMessage) => void) | null = null;
const sent = new Promise<SDKUserMessage>((resolve) => {
resolveSent = resolve;
});
queryFactory.mockImplementation((input: { prompt: AsyncIterable<SDKUserMessage> }) => {
void (async () => {
for await (const message of input.prompt) {
resolveSent?.(message);
break;
}
})();
return queryMock;
});
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.startTurn(prompt);
return await sent;
} finally {
await session.close();
}
}
const issueAttachment: AgentAttachment = {
type: "forge_issue",
mimeType: "application/paseo-forge-issue",
forge: "github",
number: 12,
title: "Fake issue for QA",
url: "https://example.invalid/acme/app/issues/12",
body: "This is an attached issue body.",
};
// Claude Code expands a slash command only when it is the last content block of the user
// message, so an auto-attached issue or a pasted screenshot must not be appended after it.
test("sends a typed slash command last when an attachment follows it", async () => {
const message = await captureSdkUserMessage(
buildAgentPrompt("/hello please", undefined, [issueAttachment]),
);
expect(message.message.content).toEqual([
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
{ type: "text", text: "/hello please" },
]);
});
test("sends a typed slash command last when an image follows it", async () => {
const message = await captureSdkUserMessage(
buildAgentPrompt("/hello please", [{ data: "aGk=", mimeType: "image/png" }], undefined),
);
expect(message.message.content).toEqual([
{ type: "image", source: { type: "base64", media_type: "image/png", data: "aGk=" } },
{ type: "text", text: "/hello please" },
]);
});
test("keeps typed text before attachments when it is not a slash command", async () => {
const message = await captureSdkUserMessage(
buildAgentPrompt("please look at this issue", undefined, [issueAttachment]),
);
expect(message.message.content).toEqual([
{ type: "text", text: "please look at this issue" },
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
]);
});
test("moves the typed slash command, not an attachment that reads like one", async () => {
const chatHistory: AgentAttachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "/earlier command quoted from another chat",
};
const message = await captureSdkUserMessage(
buildAgentPrompt("/hello please", undefined, [chatHistory, issueAttachment]),
);
expect(message.message.content).toEqual([
{ type: "text", text: chatHistory.text },
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
{ type: "text", text: "/hello please" },
]);
});
test("maps Ultracode to xhigh effort and Claude ultracode settings", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -3374,9 +3374,19 @@ class ClaudeAgentSession implements AgentSession {
};
}
> = [];
// Claude Code expands a slash command only when it is the last content block of the user
// message. buildAgentPrompt places the typed text ahead of images and rendered attachments,
// so a "/command" sent with a pasted link or a screenshot would otherwise reach the model as
// literal text instead of the command it names.
let typedSlashCommandIndex = -1;
if (Array.isArray(prompt)) {
for (const chunk of prompt) {
if (chunk.type === "text") {
// Attachments rendered as text always carry a mimeType; the composer's typed text does
// not. Only the typed block can be a slash command the user meant to invoke.
if (!("mimeType" in chunk) && this.parseSlashCommandInput(chunk.text)) {
typedSlashCommandIndex = content.length;
}
content.push({ type: "text", text: chunk.text });
} else if (chunk.type === "image") {
if (isImageMimeType(chunk.mimeType)) {
@@ -3396,6 +3406,10 @@ class ClaudeAgentSession implements AgentSession {
} else {
content.push({ type: "text", text: prompt });
}
if (typedSlashCommandIndex >= 0 && typedSlashCommandIndex < content.length - 1) {
const [slashCommand] = content.splice(typedSlashCommandIndex, 1);
content.push(slashCommand);
}
const messageId = randomUUID();
this.rememberUserMessageId(messageId);
@@ -11,6 +11,7 @@ interface ClaudeModelManifestEntry {
minimumClaudeCodeVersion?: string;
contextWindowMaxTokens?: number;
effortLevels?: readonly ClaudeEffortLevel[];
defaultThinkingOptionId?: ClaudeEffortLevel;
supportsThinkingDisabled?: boolean;
supportsFastMode?: boolean;
}
@@ -34,10 +35,21 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
export const CLAUDE_MODEL_MANIFEST = [
{
id: "claude-opus-5-5",
label: "Opus 5.5",
description: "Opus 5.5 · Latest release",
defaultPriority: 3,
minimumClaudeCodeVersion: "2.1.280",
defaultThinkingOptionId: "medium",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-opus-5",
label: "Opus 5",
description: "Opus 5 · Latest release",
description: "Opus 5 · Previous release",
defaultPriority: 2,
minimumClaudeCodeVersion: "2.1.219",
contextWindowMaxTokens: 1_000_000,
@@ -157,9 +169,14 @@ export const CLAUDE_MODEL_MANIFEST = [
},
] as const satisfies readonly ClaudeModelManifestEntry[];
function getDefaultThinkingOptionId(model: ClaudeModelManifestEntry): ClaudeEffortLevel {
return model.defaultThinkingOptionId ?? CLAUDE_DEFAULT_THINKING_OPTION_ID;
}
function buildThinkingOptions(
effortLevels: readonly ClaudeEffortLevel[] | undefined,
supportsThinkingDisabled: boolean,
defaultThinkingOptionId: ClaudeEffortLevel,
): AgentSelectOption[] | undefined {
if (!effortLevels) {
return undefined;
@@ -170,7 +187,7 @@ function buildThinkingOptions(
...effortLevels.map((id) => ({
id,
label: CLAUDE_EFFORT_LABELS[id],
...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}),
...(id === defaultThinkingOptionId ? { isDefault: true } : {}),
})),
];
@@ -196,6 +213,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
const thinkingOptions = buildThinkingOptions(
model.effortLevels,
model.supportsThinkingDisabled === true,
getDefaultThinkingOptionId(model),
);
const definition: AgentModelDefinition = {
provider: "claude",
@@ -214,7 +232,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
}
if (thinkingOptions) {
definition.thinkingOptions = thinkingOptions;
definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID;
definition.defaultThinkingOptionId = getDefaultThinkingOptionId(model);
}
definitions.push(definition);
if (!("aliases" in model) || !model.aliases) {
@@ -286,7 +304,7 @@ export function resolveClaudeDisabledThinkingForModel(
supported:
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
fallbackThinkingOptionId:
model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined,
model && "effortLevels" in model ? getDefaultThinkingOptionId(model) : undefined,
};
}
@@ -350,7 +368,9 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
/**
* Normalize a Claude Code runtime/config model string to a known manifest ID.
* Runtime metadata may include provider prefixes such as Bedrock model IDs; feature
* gates should use normalizeClaudeManifestModelId instead.
* gates should use normalizeClaudeManifestModelId instead. The prefixed matches are
* unanchored, so major-minor runs first: "claude-opus-5-5" would otherwise stop at the
* "claude-opus-5" entry.
*/
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
const normalizedManifestModelId = normalizeClaudeManifestModelId(value);
@@ -363,13 +383,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
return null;
}
const singleSegmentMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
const runtimeMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (singleSegmentMatch) {
const normalizedModelId = normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
if (runtimeMatch) {
const normalizedModelId = normalizeMajorMinorClaudeModelId(
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
trimmed.toLowerCase().includes("[1m]"),
);
if (normalizedModelId) {
@@ -377,17 +398,16 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
}
}
const runtimeMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
const singleSegmentMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
);
if (!runtimeMatch) {
if (!singleSegmentMatch) {
return null;
}
return normalizeMajorMinorClaudeModelId(
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
return normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
trimmed.toLowerCase().includes("[1m]"),
);
}
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
return configDir;
}
function createCatalogClient(claudeCodeVersion = "2.1.219"): ClaudeAgentClient {
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
@@ -50,6 +50,7 @@ describe("getClaudeModels", () => {
it("returns all claude models", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5-5",
"claude-opus-5",
"claude-fable-5-1",
"claude-fable-5",
@@ -72,7 +73,7 @@ describe("getClaudeModels", () => {
const models = getClaudeModels();
const defaults = models.filter((m) => m.isDefault);
expect(defaults).toHaveLength(1);
expect(defaults[0].id).toBe("claude-opus-5");
expect(defaults[0].id).toBe("claude-opus-5-5");
});
it("defines context window sizes in the catalog", () => {
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
expect(contextWindows).toEqual(
new Map([
["claude-opus-5-5", 1_000_000],
["claude-opus-5", 1_000_000],
["claude-fable-5-1", 1_000_000],
["claude-fable-5", 1_000_000],
@@ -109,6 +111,11 @@ describe("getClaudeModels", () => {
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
expect(getClaudeModels("2.1.279").map((model) => model.id)).not.toContain("claude-opus-5-5");
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
});
it("derives thinking options from model effort capabilities", () => {
@@ -169,6 +176,8 @@ describe("getClaudeModels", () => {
});
it.each([
["claude-opus-5-5", false, "medium"],
["claude-opus-5-5-20260401", false, "medium"],
["claude-opus-5", true, "high"],
["claude-opus-5-20260724", true, "high"],
["claude-sonnet-5", true, "high"],
@@ -417,6 +426,19 @@ describe("normalizeClaudeRuntimeModelId", () => {
"claude-opus-4-8",
);
});
it("does not collapse a prefixed minor release onto the major it extends", () => {
expect(normalizeClaudeRuntimeModelId("anthropic/claude-opus-5-5")).toBe("claude-opus-5-5");
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-5-20260401-v1:0")).toBe(
"claude-opus-5-5",
);
expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-fable-5-1")).toBe(
"claude-fable-5-1",
);
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-20260724-v1:0")).toBe(
"claude-opus-5",
);
});
});
describe("parseClaudeCodeVersion", () => {
@@ -438,7 +460,7 @@ describe("findClaudeModel", () => {
describe("Claude Opus 5 catalog", () => {
it("offers a single Opus 5 entry with a 1M context window", () => {
const opus5Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-opus-5"))
.filter((model) => /^claude-opus-5(\[1m\])?$/.test(model.id))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(opus5Models).toEqual([
@@ -515,6 +537,41 @@ describe("Claude Fable 5.1 catalog", () => {
});
});
describe("Claude Opus 5.5 catalog", () => {
it("offers one Opus 5.5 entry with a 1M context window", () => {
const opus55Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-opus-5-5"))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(opus55Models).toEqual([
{ id: "claude-opus-5-5", label: "Opus 5.5", contextWindowMaxTokens: 1_000_000 },
]);
});
it("offers every effort level except off, because Opus 5.5 cannot disable thinking", () => {
const opus55 = getClaudeModels().find((model) => model.id === "claude-opus-5-5");
expect(opus55?.thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(opus55?.defaultThinkingOptionId).toBe("medium");
expect(
opus55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
).toEqual(["medium"]);
});
it("resolves suffixed and dated Opus 5.5 IDs to the catalog entry", () => {
expect(findClaudeModel("claude-opus-5-5[1m]")?.id).toBe("claude-opus-5-5");
expect(findClaudeModel("claude-opus-5-5-20260401")?.id).toBe("claude-opus-5-5");
expect(findClaudeModel("claude-opus-5-5-20260401[1m]")?.id).toBe("claude-opus-5-5");
});
});
describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
@@ -523,6 +580,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
});
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
@@ -744,6 +744,94 @@ describe("Codex app-server provider", () => {
});
});
test("Default Permissions pins the user as approvals reviewer on thread/start", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession(
{ modeId: "auto", thinkingOptionId: "medium" },
{ autoReviewEnabled: true },
);
session.currentThreadId = null;
session.activeForegroundTurnId = null;
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "default-mode-thread" } };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
}),
};
await session.startTurn("trigger thread creation");
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall?.params).toMatchObject({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
});
test("switching from auto-review back to Default returns approvals to the user", async () => {
const session = createSession({ modeId: "auto-review" }, { autoReviewEnabled: true });
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.setMode("auto");
await session.startTurn("needs approval");
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
approvalsReviewer: "user",
}),
);
});
test("Read-only pins the user as approvals reviewer on thread/start", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession(
{ modeId: "read-only", thinkingOptionId: "medium" },
{ autoReviewEnabled: true },
);
session.currentThreadId = null;
session.activeForegroundTurnId = null;
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "read-only-thread" } };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
}),
};
await session.startTurn("trigger thread creation");
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall?.params).toMatchObject({
approvalPolicy: "on-request",
sandbox: "read-only",
approvalsReviewer: "user",
});
});
test("setMode and setThinkingOption return a next-turn notice while a turn is active", async () => {
const session = createSession({ modeId: "auto", thinkingOptionId: "medium" });
@@ -834,6 +922,7 @@ describe("Codex app-server provider", () => {
const turnStart = request.mock.calls.find(([method]) => method === "turn/start")?.[1];
expect(turnStart).not.toHaveProperty("approvalPolicy");
expect(turnStart).not.toHaveProperty("sandboxPolicy");
expect(turnStart).not.toHaveProperty("approvalsReviewer");
});
test("carries the complete native workspace-write policy including writable roots", async () => {
@@ -5991,6 +6080,138 @@ describe("Codex app-server provider", () => {
});
describe("Codex importable sessions", () => {
const CODEX_THREAD_PAGE_CAP = 100;
// Codex answers thread/list with at most 100 rows per response whatever limit
// the caller asks for, and hands back a cursor for the remaining threads.
function cappedThreadListHandler(threads: Array<Record<string, unknown>>) {
return (input: unknown) => {
const params = (input ?? {}) as {
limit?: number;
cursor?: string;
cwd?: string;
sortKey?: string;
};
const recencyKey = params.sortKey === "updated_at" ? "updatedAt" : "createdAt";
const matching = threads
.filter((thread) => !params.cwd || thread.cwd === params.cwd)
.sort((a, b) => Number(b[recencyKey]) - Number(a[recencyKey]));
const offset = Number(params.cursor ?? 0);
const page = matching.slice(
offset,
offset + Math.min(params.limit ?? CODEX_THREAD_PAGE_CAP, CODEX_THREAD_PAGE_CAP),
);
const nextOffset = offset + page.length;
return {
data: page,
nextCursor: nextOffset < matching.length ? String(nextOffset) : null,
};
};
}
function threadsUsedInReverseCreationOrder(count: number) {
// The most recently used thread is also the oldest one created, so a scan
// that follows Codex's creation-time page order misses it.
return Array.from({ length: count }, (_, index) => ({
id: `thread-${index}`,
cwd: "/workspace/project-a",
preview: `Session ${index}`,
createdAt: index,
updatedAt: count - index,
}));
}
test("fills the requested window across the pages Codex caps at 100 rows", async () => {
const threads = threadsUsedInReverseCreationOrder(250);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler([
...threads,
{ id: "other-project", cwd: "/workspace/project-b", createdAt: 9000, updatedAt: 9000 },
]),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({
limit: 240,
cwd: "/workspace/project-a",
});
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 240).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps the scanned window on the most recently used threads", async () => {
const threads = threadsUsedInReverseCreationOrder(550);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler(threads),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 700 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 500).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps a thread once when it moves onto a later page mid-scan", async () => {
// A thread used while the scan is paging sorts to the front and comes back
// on the next page.
const appServer = createFakeCodexAppServer({
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
return cursor
? {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 9000 },
{ id: "thread-3", cwd: "/workspace/project-a", updatedAt: 1000 },
],
nextCursor: null,
}
: {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 3000 },
{ id: "thread-2", cwd: "/workspace/project-a", updatedAt: 2000 },
],
nextCursor: "page-2",
};
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual([
"thread-1",
"thread-2",
"thread-3",
]);
appServer.assertNoErrors();
});
test("stops scanning when Codex pages without handing back a new thread", async () => {
const appServer = createFakeCodexAppServer({
// A cursor cycle: the two pages after the first serve threads the scan
// already holds and point at each other, so it can never fill its window.
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
const thread = { id: "thread-1", cwd: "/workspace/project-a" };
if (cursor === "page-a") return { data: [thread], nextCursor: "page-b" };
if (cursor === "page-b") return { data: [thread], nextCursor: "page-a" };
return { data: [thread], nextCursor: "page-a" };
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["thread-1"]);
appServer.assertNoErrors();
});
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
const allThreads = [
{
@@ -6071,7 +6292,10 @@ describe("Codex importable sessions", () => {
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
},
},
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
{
method: "thread/list",
params: { limit: 50, sortKey: "updated_at", cwd: "/workspace/project-a" },
},
]);
});
});
@@ -273,17 +273,19 @@ interface CodexAppServerAgentDeps {
interface CodexModePreset {
approvalPolicy: string;
sandbox: string;
approvalsReviewer?: "auto_review";
approvalsReviewer: "user" | "auto_review";
}
const MODE_PRESETS: Record<string, CodexModePreset> = {
"read-only": {
approvalPolicy: "on-request",
sandbox: "read-only",
approvalsReviewer: "user",
},
auto: {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
},
"auto-review": {
approvalPolicy: "on-request",
@@ -293,6 +295,7 @@ const MODE_PRESETS: Record<string, CodexModePreset> = {
"full-access": {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
},
};
@@ -300,15 +303,6 @@ function isAutoReviewReviewer(value: string | undefined): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function applyApprovalsReviewerParam(
params: Record<string, unknown>,
preset: CodexModePreset,
): void {
if (preset.approvalsReviewer) {
params.approvalsReviewer = preset.approvalsReviewer;
}
}
function shouldPromoteThreadResponseToAutoReview(params: {
approvalsReviewer: string | undefined;
approvalPolicy: string;
@@ -914,6 +908,60 @@ const CodexModelListResponseSchema = z.object({
.optional(),
});
/**
* Read the newest `window.limit` threads Codex knows about.
*
* Codex caps every `thread/list` response at 100 rows whatever limit it is
* given (codex-cli 0.153 and 0.155), so one request only ever sees the newest
* page and hands back a cursor for the rest. Follow that cursor until the
* caller's window is full or Codex runs out of threads.
*/
async function readCodexThreadWindow(
client: Pick<CodexAppServerClientLike, "request">,
logger: Logger,
window: { limit: number; cwd?: string },
): Promise<Array<Record<string, unknown>>> {
// A thread updated while the scan is paging moves under `updated_at` order
// and can come back on a later page, so identify each one and keep it once.
// A row Codex sends without an id stands for itself.
const threads = new Map<unknown, Record<string, unknown>>();
let cursor: string | undefined;
while (threads.size < window.limit) {
const response = toObjectRecord(
await client.request("thread/list", {
limit: window.limit - threads.size,
// Rank the window by last use. Codex pages by creation time by default,
// which drops an old conversation that is still being worked in.
// Older Codex builds ignore the unknown key and keep that order.
sortKey: "updated_at",
...(window.cwd ? { cwd: window.cwd } : {}),
...(cursor ? { cursor } : {}),
}),
);
const page = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const sizeBeforePage = threads.size;
for (const thread of page) {
const identity = typeof thread.id === "string" ? thread.id : thread;
if (!threads.has(identity)) threads.set(identity, thread);
}
const nextCursor = typeof response?.nextCursor === "string" ? response.nextCursor : undefined;
if (!nextCursor) break;
if (threads.size === sizeBeforePage) {
// Every page that continues the scan brings a thread the scan has not
// seen, so the loop is bounded by the window. A page that brings none
// would let a Codex-side cursor bug (a stuck cursor, or a cycle) page for
// ever behind a caller that has already timed out.
logger.warn(
{ cursor: nextCursor },
"codex thread/list returned no new threads mid-scan, stopping the session scan",
);
break;
}
cursor = nextCursor;
}
return [...threads.values()];
}
function filterCodexThreadsByCwd(
threads: Array<Record<string, unknown>>,
cwd: string | undefined,
@@ -4097,7 +4145,7 @@ export class CodexAppServerAgentSession implements AgentSession {
: toSandboxPolicy(sandboxPolicyType, workspaceWrite);
}
if (this.hasWorkflowModeOverride) {
applyApprovalsReviewerParam(params, preset);
params.approvalsReviewer = preset.approvalsReviewer;
}
return { approvalPolicy, sandboxPolicyType };
}
@@ -5151,7 +5199,7 @@ export class CodexAppServerAgentSession implements AgentSession {
...(this.ephemeral ? { ephemeral: true } : {}),
};
if (this.hasWorkflowModeOverride) {
applyApprovalsReviewerParam(params, preset);
params.approvalsReviewer = preset.approvalsReviewer;
}
return { params, approvalPolicy, sandbox };
}
@@ -7143,13 +7191,10 @@ export class CodexAppServerAgentClient implements AgentClient {
// filtering since most threads will be from other cwds, then keep the
// local realpath-aware filter for symlink-equivalent workspace paths.
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
const response = toObjectRecord(
await client.request("thread/list", {
limit: listLimit,
...(options?.cwd ? { cwd: options.cwd } : {}),
}),
);
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const allThreads = await readCodexThreadWindow(client, this.logger, {
limit: listLimit,
cwd: options?.cwd,
});
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
return threads.slice(0, limit).map((thread) => {
const threadId = typeof thread.id === "string" ? thread.id : "";
@@ -268,6 +268,41 @@ describe("JsonlRpcProcess", () => {
},
);
test("requestStopWork resolves once the child has exited", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
child.emit("exit", 1, null);
await expect(transport.requestStopWork({ type: "abort" })).resolves.toBeUndefined();
});
// A dying child's stdin pipe breaks a fraction before its exit event, so deciding at the
// moment of the write failure would refuse a stop the runtime did honor.
test("requestStopWork resolves when stdin fails just before the child exits", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
const stopped = transport.requestStopWork({ type: "abort" });
child.stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
await expect(stopped).resolves.toBeUndefined();
});
// `close()` and a spawn error dispose the transport before the child is known to be
// gone, and a child can outlive SIGKILL, so a disposed transport is not proof that the
// requested work stopped.
test("requestStopWork keeps failing while the transport is disposed but the child has not exited", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
child.emit("error", new Error("spawn failed"));
await expect(transport.requestStopWork({ type: "abort" })).rejects.toThrow(
"JSONL RPC process is closed",
);
});
test("stdin error events close the transport instead of becoming uncaught exceptions", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
@@ -81,6 +81,8 @@ export class JsonlRpcProcess {
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private exited = false;
private closing: Promise<void> | null = null;
private readonly frameDecoder: JsonlFrameDecoder;
constructor(private readonly options: JsonlRpcProcessOptions) {
@@ -111,6 +113,7 @@ export class JsonlRpcProcess {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
this.child.on("exit", (code, signal) => {
this.exited = true;
const error = new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
@@ -175,6 +178,36 @@ export class JsonlRpcProcess {
return this.startRequest(command, timeoutMs).promise;
}
/**
* Send a command whose goal an exited process has already met — stopping a turn,
* clearing a queue. Resolves once the child has exited instead of rejecting,
* because nothing is queued and nothing is running, which is what the caller
* asked for. Use `request` when the caller needs an answer from a live process.
*
* Keyed on an observed exit rather than on `disposed`: `close()` disposes the
* transport before termination completes, and a child that outlives SIGKILL can
* still be working, so a disposed transport is not proof the work stopped.
*/
async requestStopWork(
command: { type: string; [key: string]: unknown },
timeoutMs?: number | null,
): Promise<void> {
if (this.exited) {
return;
}
try {
await this.request(command, timeoutMs);
} catch (error) {
// A dying child breaks its stdin pipe a fraction before it emits `exit`, which
// fails this request and starts a termination. Wait for that termination to reach
// an answer rather than refusing a stop the runtime went on to honor.
await this.closing?.catch(() => undefined);
if (!this.exited) {
throw error;
}
}
}
send(message: Record<string, unknown>): void {
if (this.disposed) {
return;
@@ -193,6 +226,11 @@ export class JsonlRpcProcess {
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
if (this.disposed) return;
this.failAll(error);
this.closing = this.terminate();
await this.closing;
}
private async terminate(): Promise<void> {
try {
this.child.stdin.end();
} catch {
@@ -1,12 +1,37 @@
import { describe, expect, test } from "vitest";
import { setImmediate as waitForImmediate } from "node:timers/promises";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
import type { OmpAgentMessage } from "./rpc-types.js";
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
import type { OmpUsagePollScheduler } from "./usage-poller.js";
import { resolveOmpProviderParams } from "./provider-config.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
"turn_started",
"turn_completed",
"turn_failed",
"turn_canceled",
]);
function isTurnLifecycle(type: AgentStreamEvent["type"]): boolean {
return TURN_LIFECYCLE_EVENTS.has(type);
}
// What OMP reports for a turn the user stopped: an error message on a terminal
// response whose stop reason says the request was aborted.
const ABORTED_TERMINAL_RESPONSE: OmpAgentMessage = {
role: "assistant",
content: [],
provider: "ai-harness-omp",
model: "glm-5.3-flash-high",
responseId: "chatcmpl-aborted",
stopReason: "aborted",
errorMessage: "Interrupted by user",
};
test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => {
expect(resolveOmpProviderParams({}).runtimeProviderParams).toMatchObject({
readyTimeoutMs: 20_000,
@@ -624,6 +649,74 @@ describe("OMP agent client and session", () => {
expect(omp.runningToolCallIds()).toEqual([]);
});
test("an interrupt that OMP reports as an aborted turn cancels instead of failing", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("do something long");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.emit({
type: "tool_execution_start",
toolCallId: "tool-1",
toolName: "bash",
args: { command: "sleep 30" },
});
runtime.streamAssistantText("working on it");
// OMP ends the turn with an aborted terminal response before answering the abort.
runtime.onAbort = () => {
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
};
await omp.interrupt();
await waitForImmediate();
await waitForImmediate();
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
expect(omp.runningToolCallIds()).toEqual([]);
});
test("an aborted turn that settles after the interrupt cancels exactly once", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("do something long");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.streamAssistantText("working on it");
// The provider-idle check is still in flight when the abort is acknowledged.
const releaseState = runtime.holdStateRequests();
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
await omp.interrupt();
releaseState();
await waitForImmediate();
await waitForImmediate();
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
});
test("an autonomous OMP turn aborted with no client turn id cancels", async () => {
const omp = new OmpHarness();
await omp.start();
const runtime = omp.runtime();
runtime.beginTurn();
runtime.streamAssistantText("autonomous work");
runtime.onAbort = () => {
runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE });
runtime.finishTurn(ABORTED_TERMINAL_RESPONSE);
};
await omp.interrupt();
await waitForImmediate();
await waitForImmediate();
expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]);
});
test("a resumed session does not re-emit replayed events as live timeline items", async () => {
const omp = new OmpHarness();
await omp.resume({
@@ -526,6 +526,11 @@ function latestOmpErrorMessage(messages: OmpAgentMessage[]): string | null {
return formatOmpErrorMessage(latestAssistant);
}
function isOmpAbortedTerminalResponse(messages: OmpAgentMessage[]): boolean {
const latestAssistant = messages.findLast((message) => message.role === "assistant");
return latestAssistant?.stopReason?.toLowerCase() === "aborted";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -2133,6 +2138,19 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.clearNoTurnBuffers();
// OMP reports a stopped turn as a terminal response carrying its interrupt
// text as an error. That is the user's own Stop, not a failed turn.
if (isOmpAbortedTerminalResponse(messages)) {
this.usagePoller.stopTurn();
this.terminalizeActiveWork();
this.emit({
type: "turn_canceled",
provider: this.provider,
turnId,
reason: "interrupted",
});
return;
}
const errorMessage = latestOmpErrorMessage(messages);
if (typeof errorMessage === "string" && errorMessage.length > 0) {
this.usagePoller.stopTurn();
@@ -2161,6 +2179,10 @@ export class OmpAgentSession implements AgentSession {
try {
const state = await this.runtimeSession.getState();
this.state = state;
if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) {
// An interrupt settled this turn while the state check was in flight.
return;
}
if (!state.isStreaming && !state.isCompacting) {
this.completeTurn(turnId, messages);
return;
@@ -66,10 +66,7 @@ function createRuntime(
});
}
function replyToCommands(
child: OmpChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
function onOmpCommand(child: OmpChild, handler: (command: Record<string, unknown>) => void): void {
let buffer = "";
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
@@ -78,17 +75,34 @@ function replyToCommands(
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
handler(JSON.parse(line) as Record<string, unknown>);
}
});
}
function replyToCommands(
child: OmpChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
onOmpCommand(child, (command) => {
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
});
}
/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */
function exitOnCommand(child: OmpChild, type: string): void {
onOmpCommand(child, (command) => {
if (command.type === type) {
child.emit("exit", 1, null);
}
});
}
@@ -418,4 +432,24 @@ describe("OMP CLI runtime", () => {
expect(result).toHaveLength(2_000);
expect(result[0]).toEqual(expect.objectContaining({ id: "model-0" }));
});
// A dead runtime owns no turn, so aborting it is already satisfied. Rejecting here
// makes AgentManager treat the interrupt as unacknowledged and refuse the stop, which
// pins the agent at `running` until the daemon restarts. See issue #3749.
test("abort resolves once the OMP process has exited", async () => {
const child = createOmpChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
child.emit("exit", 1, null);
await expect(session.abort()).resolves.toBeUndefined();
});
test("abort resolves when the OMP process exits while the abort is in flight", async () => {
const child = createOmpChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
exitOnCommand(child, "abort");
await expect(session.abort()).resolves.toBeUndefined();
});
});
@@ -155,7 +155,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
}
async abort(): Promise<void> {
await this.request({ type: "abort" });
await this.requestStopWork({ type: "abort" });
}
async getState(): Promise<OmpSessionState> {
@@ -292,6 +292,10 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
}
private requestStopWork(command: OmpRpcCommand): Promise<void> {
return this.process.requestStopWork(OmpRpcCommandSchema.parse(command));
}
private emit(event: OmpRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
@@ -113,6 +113,8 @@ export class FakeOmpSession implements OmpRuntimeSession {
getStateRequestCount = 0;
abortRequested = false;
abortError: Error | null = null;
onAbort: (() => void) | null = null;
private heldStateRequests: Array<() => void> | null = null;
readonly canceledExtensionUiRequests: string[] = [];
readonly extensionUiResponses: Array<{
id: string;
@@ -243,6 +245,8 @@ export class FakeOmpSession implements OmpRuntimeSession {
throw this.abortError;
}
this.abortRequested = true;
// OMP can stream a turn's teardown before it answers the abort request.
this.onAbort?.();
}
async getState(): Promise<OmpSessionState> {
@@ -251,6 +255,10 @@ export class FakeOmpSession implements OmpRuntimeSession {
if (this.getStateRequestCount >= waiter.count) waiter.resolve();
else this.stateRequestWaiters.push(waiter);
}
if (this.heldStateRequests) {
const held = this.heldStateRequests;
await new Promise<void>((resolve) => held.push(resolve));
}
if (this.getStateError) {
throw this.getStateError;
}
@@ -261,6 +269,16 @@ export class FakeOmpSession implements OmpRuntimeSession {
return this.state;
}
/** Holds every state request until the returned function releases them. */
holdStateRequests(): () => void {
const held: Array<() => void> = [];
this.heldStateRequests = held;
return () => {
this.heldStateRequests = null;
for (const resolve of held.splice(0)) resolve();
};
}
queueStateReports(states: OmpSessionState[]): void {
this.stateReports.push(...states);
}
@@ -62,6 +62,15 @@ function onPiCommand(child: PiChild, handler: (command: Record<string, unknown>)
});
}
/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */
function exitOnCommand(child: PiChild, type: string): void {
onPiCommand(child, (command) => {
if (command.type === type) {
child.emit("exit", 1, null);
}
});
}
function replyToCommands(
child: PiChild,
handler: (command: Record<string, unknown>) => unknown,
@@ -506,4 +515,25 @@ describe("PiCliRuntime", () => {
// Neither RPC returned usable data — should resolve with empty object
expect(stats).toEqual({});
});
// A dead runtime owns no turn, so interrupting it is already satisfied. Rejecting here
// makes AgentManager treat the interrupt as unacknowledged and refuse the stop, which
// pins the agent at `running` until the daemon restarts. See issue #3749.
test("abort and clearQueue resolve once the Pi process has exited", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
child.emit("exit", 1, null);
await expect(session.clearQueue()).resolves.toBeUndefined();
await expect(session.abort()).resolves.toBeUndefined();
});
test("abort resolves when the Pi process exits while the abort is in flight", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
exitOnCommand(child, "abort");
await expect(session.abort()).resolves.toBeUndefined();
});
});
@@ -133,7 +133,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async clearQueue(): Promise<void> {
await this.request({ type: "clear_queue" });
await this.requestStopWork({ type: "clear_queue" });
}
async compact(customInstructions?: string): Promise<void> {
@@ -148,7 +148,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async abort(): Promise<void> {
await this.request({ type: "abort" });
await this.requestStopWork({ type: "abort" });
}
async getState(): Promise<PiSessionState> {
@@ -237,6 +237,10 @@ class PiCliRuntimeSession implements PiRuntimeSession {
return this.process.request(command, timeoutMs);
}
private requestStopWork(command: PiRpcCommand): Promise<void> {
return this.process.requestStopWork(command);
}
private async waitForCompletion(command: PiRpcCommand): Promise<void> {
// Pi only replies after its compaction work is durable. Its child process and
// session close paths already reject pending RPCs, so no elapsed-time failure
@@ -64,6 +64,33 @@ describe("projectTimelineRows", () => {
});
});
test("keeps one assistant message whole when a steering user message lands mid-stream", () => {
const rows: AgentTimelineRow[] = [
{
seq: 1,
timestamp: "2026-02-13T00:00:00.000Z",
item: { type: "assistant_message", text: "with the uncommit", messageId: "msg-1" },
},
{
seq: 2,
timestamp: "2026-02-13T00:00:00.100Z",
item: { type: "user_message", text: "keep going" },
},
{
seq: 3,
timestamp: "2026-02-13T00:00:00.200Z",
item: { type: "assistant_message", text: "ted changes.", messageId: "msg-1" },
},
];
const projected = projectTimelineRows({ rows, mode: "projected" });
expect(projected.map((entry) => entry.item)).toEqual([
{ type: "assistant_message", text: "with the uncommitted changes.", messageId: "msg-1" },
{ type: "user_message", text: "keep going" },
]);
});
test("keeps adjacent assistant chunks with different message ids separate in projected mode", () => {
const rows: AgentTimelineRow[] = [
{
@@ -155,6 +155,17 @@ function mergeIdentityEntries(existing: WorkingEntry, entry: WorkingEntry): Work
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
...mergeIdentityMetadata(existing, entry, "tool_lifecycle"),
};
case "assistant_message":
if (existing.item.type !== "assistant_message" || existing.turnId !== entry.turnId) {
return null;
}
return {
...existing,
item: { ...existing.item, text: `${existing.item.text}${entry.item.text}` },
timestamp: entry.timestamp,
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
...mergeIdentityMetadata(existing, entry, "assistant_merge"),
};
case "plugin":
if (existing.item.type !== "plugin") return null;
return {
+2 -1
View File
@@ -2,7 +2,7 @@ import { describeHookWorkspace } from "./plugins/lifecycle/index.js";
import express from "express";
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
import { constants, existsSync, unlinkSync } from "fs";
import { open, rm } from "fs/promises";
import { open, rm, stat } from "fs/promises";
import { randomUUID } from "node:crypto";
import { hostname as getHostname } from "node:os";
import path from "node:path";
@@ -891,6 +891,7 @@ export async function createPaseoDaemon(
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async (target) => (await stat(target).catch(() => null))?.isDirectory() ?? false,
logger,
});
const agentProviderRuntime = await createAgentProviderRuntime({
@@ -1117,6 +1117,7 @@ function createDeps(options?: {
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
logger: createTestLogger(),
});
@@ -212,6 +212,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
});
return {
workspaceRegistry,
@@ -154,12 +154,15 @@ test("create_agent_request creates a worktree and auto-archives both after the f
// last-reference worktree directory is gone.
await expectAgentAbsentFromActiveList(created.id);
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
// Archived tabs can continue asking for history. These reads must not recreate
// the removed workspace observation or compromise the next agent lifecycle.
// Archived tabs can continue asking for history, and those reads are served from
// persisted state. They must not recreate the removed worktree or its workspace
// observation, and must not compromise the next agent lifecycle.
const staleTimelineReads = await Promise.allSettled(
Array.from({ length: 10 }, () => ctx.client.fetchAgentTimeline(created.id, { limit: 20 })),
);
expect(staleTimelineReads.every((result) => result.status === "rejected")).toBe(true);
expect(staleTimelineReads.every((result) => result.status === "fulfilled")).toBe(true);
expect(existsSync(created.cwd)).toBe(false);
expect((await ctx.client.fetchWorkspaces()).entries).toHaveLength(0);
const subsequent = await ctx.client.createAgent({
config: { ...getFullAccessConfig("codex"), cwd: repoDir },
initialPrompt: "Say done.",
+1
View File
@@ -902,6 +902,7 @@ export class Session {
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService,
isDirectory: (path) => this.filesystem.isDirectory(path),
logger: this.sessionLogger,
});
this.workspaceRecovery = createWorkspaceRecoveryService({
@@ -1,9 +1,10 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs";
import { afterEach, beforeEach, expect, test } from "vitest";
import { getCheckoutStatus } from "../../../utils/checkout-git.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import {
createNoGitWorkspaceRuntimeSnapshot,
@@ -16,6 +17,7 @@ import {
createPersistedWorkspaceRecord,
type WorkspaceRegistry,
} from "../../workspace-registry.js";
import { checkoutLiteFromGitSnapshot } from "../../workspace-registry-model.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import {
createWorkspaceProvisioningService,
@@ -31,6 +33,15 @@ const logger = createTestLogger();
const ARCHIVED_AT = "2026-01-01T00:00:00.000Z";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
// The real filesystem, so "this directory is gone" is observed rather than modelled.
async function isDirectory(target: string): Promise<boolean> {
try {
return statSync(target).isDirectory();
} catch {
return false;
}
}
let tmpDir: string;
let gitRoots: Set<string>;
let gitBranches: Map<string, string | null>;
@@ -106,6 +117,7 @@ beforeEach(async () => {
workspaceRegistry,
projectRegistry,
workspaceGitService: gitService(),
isDirectory,
logger,
});
});
@@ -161,6 +173,7 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an
test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
const first = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.upsert({ ...first, title: "Pinned work" });
gitRoots.add(repo);
@@ -187,6 +200,7 @@ test("persists manual worktree ownership separately from its workspace kind", as
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
isDirectory,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async () => ({
@@ -224,6 +238,7 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps
test("reopening archived exact-root records restores the fresh Git project", async () => {
const cwd = path.join(tmpDir, "repo");
mkdirSync(cwd, { recursive: true });
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: cwd,
kind: "non_git",
@@ -249,6 +264,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
const archivedProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
isDirectory,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async () => ({
@@ -279,6 +295,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
test("uses one workspace snapshot when reopening an archived workspace", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
@@ -300,6 +317,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
workspaceRegistry: snapshotRegistry,
projectRegistry,
workspaceGitService: gitService(),
isDirectory,
});
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
@@ -310,6 +328,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
test("reopening an archived workspace refreshes placement without renaming it", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" });
@@ -430,6 +449,7 @@ test("ensureWorkspaceRecordUnarchived refreshes the latch for a different merged
test("does not unarchive either record when checkout refresh fails", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await projectRegistry.archive(created.projectId, ARCHIVED_AT);
@@ -767,3 +787,136 @@ test.each(["missing", "archived"] as const)(
expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []);
},
);
test("a failed import keeps an archived worktree's placement when its directory is gone", async () => {
// The git port here is the production read: getCheckoutStatus against the real
// filesystem, so "the worktree directory is gone" is observed, not modelled.
const mainRepoRoot = path.join(tmpDir, "main-repo");
mkdirSync(mainRepoRoot, { recursive: true });
const worktreeCwd = path.join(tmpDir, "worktrees", "feature-example");
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: mainRepoRoot,
kind: "git",
displayName: "main-repo",
timestamp: ARCHIVED_AT,
});
const archived = createPersistedWorkspaceRecord({
workspaceId: "ws-archived-worktree",
projectId: project.projectId,
cwd: worktreeCwd,
kind: "worktree",
displayName: "feature/example",
branch: "feature/example",
worktreeRoot: worktreeCwd,
baseBranch: "main",
isPaseoOwnedWorktree: true,
mainRepoRoot,
createdAt: ARCHIVED_AT,
updatedAt: ARCHIVED_AT,
archivedAt: ARCHIVED_AT,
});
await workspaceRegistry.upsert(archived);
const realCheckoutProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
logger,
isDirectory,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async (cwd: string) => {
const status = await getCheckoutStatus(cwd, { logger });
return checkoutLiteFromGitSnapshot(
cwd,
status.isGit
? {
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
repoRoot: status.repoRoot,
isPaseoOwnedWorktree: status.isPaseoOwnedWorktree,
mainRepoRoot: status.mainRepoRoot,
}
: {
isGit: false,
currentBranch: null,
remoteUrl: null,
repoRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
);
},
}),
});
await expect(
realCheckoutProvisioning.runInImportWorkspace({ cwd: worktreeCwd }, async () => {
throw new Error("provider resume failed");
}),
).rejects.toThrow("provider resume failed");
expect(await workspaceRegistry.get(archived.workspaceId)).toMatchObject({
kind: "worktree",
branch: "feature/example",
worktreeRoot: worktreeCwd,
mainRepoRoot,
isPaseoOwnedWorktree: true,
});
});
test("re-opening an active worktree workspace keeps its placement while the directory is away", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
gitBranches.set(repo, "feature/away");
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/away" });
rmSync(repo, { recursive: true, force: true });
gitRoots.delete(repo);
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(reopened).toMatchObject({
workspaceId: created.workspaceId,
kind: "local_checkout",
branch: "feature/away",
});
});
test("a directory that goes away while the git read is in flight keeps its placement", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
gitBranches.set(repo, "feature/vanishing");
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/vanishing" });
const vanishingProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
isDirectory,
logger,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async (cwd: string) => {
rmSync(repo, { recursive: true, force: true });
return {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
},
}),
});
const reopened = await vanishingProvisioning.findOrCreateWorkspaceForDirectory(repo);
expect(reopened).toMatchObject({
workspaceId: created.workspaceId,
kind: "local_checkout",
branch: "feature/vanishing",
});
});
@@ -1,6 +1,7 @@
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
import { basename, resolve } from "node:path";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import type { Logger } from "pino";
import {
generateWorkspaceId,
@@ -94,11 +95,27 @@ export function createWorkspaceProvisioningService(deps: {
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
isDirectory: (path: string) => Promise<boolean>;
logger: Logger;
lifecycle?: PluginLifecycle;
}): WorkspaceProvisioningService {
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
/**
* Placement facts at a workspace directory, or null when there is nothing
* there to read. A git read answers "not a checkout" for a plain directory
* and for a directory that is gone, and only the first is evidence that a
* worktree stopped being one. That answer therefore counts only while the
* directory is there on both sides of the read, so a worktree removed or
* unmounted while the read is in flight stays an absence.
*/
async function observeWorkspaceCheckout(cwd: string): Promise<ProjectCheckoutLitePayload | null> {
if (!(await deps.isDirectory(cwd))) return null;
const checkout = await workspaceGitService.getCheckout(cwd);
if (!checkout.isGit && !(await deps.isDirectory(cwd))) return null;
return checkout;
}
async function runInImportWorkspace<T>(
input: ImportWorkspaceInput,
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
@@ -366,12 +383,12 @@ export function createWorkspaceProvisioningService(deps: {
const timestamp = new Date().toISOString();
const checkout =
workspace.archivedAt || project.archivedAt
? await workspaceGitService.getCheckout(workspace.cwd)
? await observeWorkspaceCheckout(workspace.cwd)
: null;
const autoArchivedChangeRequestUrl =
await resolveRestoredAutoArchiveChangeRequestUrl(workspace);
let next: PersistedWorkspaceRecord | null = null;
if (workspace.archivedAt && checkout) {
if (workspace.archivedAt) {
const placementUpdate = reconcileWorkspacePlacement({
workspace,
checkout,
@@ -384,10 +401,11 @@ export function createWorkspaceProvisioningService(deps: {
updatedAt: timestamp,
};
}
if (checkout && (project.archivedAt || workspace.archivedAt)) {
const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd)
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
if (project.archivedAt || workspace.archivedAt) {
const projectCheckout =
checkout && areEquivalentPaths(project.rootPath, workspace.cwd)
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
const kind = projectCheckout.isGit ? "git" : "non_git";
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
@@ -414,10 +432,10 @@ export function createWorkspaceProvisioningService(deps: {
async function refreshWorkspaceRecord(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
const checkout = await workspaceGitService.getCheckout(workspace.cwd);
const checkout = await observeWorkspaceCheckout(workspace.cwd);
const project = await projectRegistry.get(workspace.projectId);
if (project && !project.archivedAt) {
await refreshProjectKind(project, workspace.cwd, checkout);
await refreshProjectKind(project, workspace.cwd, checkout ?? undefined);
}
const update = reconcileWorkspacePlacement({
workspace,
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import { areEquivalentPaths } from "../utils/path.js";
import { deriveProjectKey } from "./project-key.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -336,6 +337,14 @@ class ObservedPlacements {
projectId: spec.id,
rootPath,
kind: "non_git",
// Seeded records already carry the key reconciliation derives, so a fixture's
// first pass reports only what its test is about.
projectKey: deriveProjectKey({
rootPath,
remoteUrl: null,
worktreeRoot: null,
mainRepoRoot: null,
}),
displayName: spec.id,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -473,7 +482,11 @@ describe("observed workspace placement", () => {
test("archives missing workspace directories on the periodic pass", async () => {
const observed = new ObservedPlacements([
{ id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] },
{
id: "project-one",
root: "repo",
workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }],
},
]);
await observed.start();
await observed.deleteWorkspaceDirectory("workspace-one");
@@ -487,7 +500,11 @@ describe("observed workspace placement", () => {
test("preserves a periodic full pass queued behind metadata reconciliation", async () => {
const observed = new ObservedPlacements([
{ id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] },
{
id: "project-one",
root: "repo",
workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }],
},
]);
await observed.start();
const metadataRead = observed.holdNextReconciliation();
@@ -598,6 +598,11 @@ describe("WorkspaceReconciliationService", () => {
});
test("archives workspaces whose directories no longer exist", async () => {
const projectRoot = realpathSync(
mkdtempSync(path.join(tmpdir(), "reconcile-missing-workspace-")),
);
const missingWorkspace = path.join(projectRoot, "missing-workspace");
tempDirs.push(projectRoot);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
const archivedWorkspaceIds: string[] = [];
@@ -605,7 +610,8 @@ describe("WorkspaceReconciliationService", () => {
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath: "/tmp/does-not-exist-reconcile-test",
rootPath: projectRoot,
projectKey: canonicalLocalProjectKey(projectRoot),
kind: "non_git",
displayName: "ghost",
createdAt: timestamp,
@@ -617,7 +623,7 @@ describe("WorkspaceReconciliationService", () => {
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: "/tmp/does-not-exist-reconcile-test",
cwd: missingWorkspace,
kind: "directory",
displayName: "ghost",
createdAt: timestamp,
@@ -640,7 +646,7 @@ describe("WorkspaceReconciliationService", () => {
{
kind: "workspace_archived",
workspaceId: "w1",
directory: "/tmp/does-not-exist-reconcile-test",
directory: missingWorkspace,
reason: "directory_missing",
},
]);
@@ -648,12 +654,59 @@ describe("WorkspaceReconciliationService", () => {
expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String));
});
test("keeps workspaces whose project root is missing with them", async () => {
const mountParent = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-unmounted-")));
tempDirs.push(mountParent);
// The external volume is not mounted, so nothing under it resolves.
const projectRoot = path.join(mountParent, "ExternalSSD", "repo");
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath: projectRoot,
kind: "non_git",
displayName: "repo",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
"w1",
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: projectRoot,
kind: "directory",
displayName: "repo",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
const result = await service.runOnce();
expect(result.changesApplied).toEqual([]);
expect(workspaces.get("w1")?.archivedAt).toBeNull();
});
test("keeps a project active after all its workspaces are archived", async () => {
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-orphan-project-")));
const missingWorkspace = path.join(projectRoot, "missing-workspace");
tempDirs.push(projectRoot);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
const project = createPersistedProjectRecord({
projectId: "p1",
rootPath: "/tmp/does-not-exist-reconcile-orphan",
rootPath: projectRoot,
projectKey: canonicalLocalProjectKey(projectRoot),
kind: "non_git",
displayName: "orphan",
createdAt: timestamp,
@@ -665,7 +718,7 @@ describe("WorkspaceReconciliationService", () => {
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: "/tmp/does-not-exist-reconcile-orphan",
cwd: missingWorkspace,
kind: "directory",
displayName: "orphan",
createdAt: timestamp,
@@ -685,14 +738,14 @@ describe("WorkspaceReconciliationService", () => {
{
kind: "workspace_archived",
workspaceId: "w1",
directory: "/tmp/does-not-exist-reconcile-orphan",
directory: missingWorkspace,
reason: "directory_missing",
},
]);
expect(workspaces.get("w1")).toEqual({
workspaceId: "w1",
projectId: "p1",
cwd: "/tmp/does-not-exist-reconcile-orphan",
cwd: missingWorkspace,
kind: "directory",
displayName: "orphan",
title: null,
@@ -1382,13 +1435,17 @@ describe("WorkspaceReconciliationService", () => {
});
test("calls onChanges callback when changes are applied", async () => {
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-callback-")));
const missingWorkspace = path.join(projectRoot, "missing-workspace");
tempDirs.push(projectRoot);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath: "/tmp/does-not-exist-callback-test",
rootPath: projectRoot,
projectKey: canonicalLocalProjectKey(projectRoot),
kind: "non_git",
displayName: "ghost",
createdAt: timestamp,
@@ -1400,7 +1457,7 @@ describe("WorkspaceReconciliationService", () => {
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: "/tmp/does-not-exist-callback-test",
cwd: missingWorkspace,
kind: "directory",
displayName: "ghost",
createdAt: timestamp,
@@ -1422,13 +1479,16 @@ describe("WorkspaceReconciliationService", () => {
{
kind: "workspace_archived",
workspaceId: "w1",
directory: "/tmp/does-not-exist-callback-test",
directory: missingWorkspace,
reason: "directory_missing",
},
]);
});
test("logs reconciliation changes with affected paths and reasons", async () => {
const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-log-")));
const missingWorkspace = path.join(projectRoot, "missing-workspace");
tempDirs.push(projectRoot);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
const { logger, infoRecords } = createCapturingLogger();
@@ -1436,7 +1496,8 @@ describe("WorkspaceReconciliationService", () => {
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath: "/tmp/does-not-exist-log-test",
rootPath: projectRoot,
projectKey: canonicalLocalProjectKey(projectRoot),
kind: "non_git",
displayName: "ghost",
createdAt: timestamp,
@@ -1448,7 +1509,7 @@ describe("WorkspaceReconciliationService", () => {
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: "/tmp/does-not-exist-log-test",
cwd: missingWorkspace,
kind: "directory",
displayName: "ghost",
createdAt: timestamp,
@@ -1473,7 +1534,7 @@ describe("WorkspaceReconciliationService", () => {
{
kind: "workspace_archived",
workspaceId: "w1",
directory: "/tmp/does-not-exist-log-test",
directory: missingWorkspace,
reason: "directory_missing",
},
]),
@@ -227,6 +227,14 @@ export class WorkspaceReconciliationService {
workspace,
state: this.inspectDirectory(workspace.cwd),
}));
// Project roots are read after the workspace directories, so a volume that
// goes away mid-pass leaves its project unreachable rather than its workspaces
// alone. The skew can only withhold an archive, never produce one.
const reachableProjectIds = new Set(
activeProjects
.filter((project) => this.inspectDirectory(project.rootPath) === "directory")
.map((project) => project.projectId),
);
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
for (const { workspace, state } of workspaceDirectoryStates) {
@@ -236,9 +244,16 @@ export class WorkspaceReconciliationService {
workspacesByProject.set(workspace.projectId, list);
}
// 1. Archive workspaces whose directories no longer exist
// 1. Archive workspaces whose directories no longer exist, but only when the
// project they belong to is still reachable. A missing project root means the
// whole location is unavailable - an unmounted volume, an offline share, a disk
// that has not appeared yet - and absence there proves nothing about the
// workspace. Projects already persist through that; their workspaces do too.
const missingWorkspaces = workspaceDirectoryStates
.filter(({ state }) => state === "missing")
.filter(
({ workspace, state }) =>
state === "missing" && reachableProjectIds.has(workspace.projectId),
)
.map(({ workspace }) => workspace);
await Promise.all(
missingWorkspaces.map(async (workspace) => {
@@ -265,7 +280,7 @@ export class WorkspaceReconciliationService {
// Projects persist until explicitly removed, even when they currently have
// zero active workspaces, so they still reconcile their own metadata.
await this.reconcileGitMetadataForProjects(
activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"),
activeProjects.filter((project) => reachableProjectIds.has(project.projectId)),
workspacesByProject,
changes,
);
@@ -87,6 +87,31 @@ describe("workspace placement", () => {
});
});
test("keeps the recorded placement when nothing was read at the workspace directory", () => {
const workspace = createPersistedWorkspaceRecord({
workspaceId: "workspace-one",
projectId: "project-one",
cwd: "/repo-feature",
kind: "worktree",
displayName: "feature/placement",
branch: "feature/placement",
worktreeRoot: "/repo-feature",
baseBranch: "main",
isPaseoOwnedWorktree: true,
mainRepoRoot: "/repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
const update = reconcileWorkspacePlacement({
workspace,
checkout: null,
updatedAt: "2026-03-02T00:00:00.000Z",
});
expect(update).toBeNull();
});
test("updates live placement while preserving its durable name and base branch", () => {
const workspace = createPersistedWorkspaceRecord({
workspaceId: "workspace-one",
@@ -109,12 +109,17 @@ export function initialWorkspacePlacement(
/**
* Applies live placement facts without rewriting the workspace's durable name
* or its creation-time base branch.
*
* A null checkout is an absence, not an observation: nothing was read at the
* workspace directory, so the recorded placement stands. Only a directory that
* is there can show that a worktree stopped being one.
*/
export function reconcileWorkspacePlacement(input: {
workspace: PersistedWorkspaceRecord;
checkout: ProjectCheckoutLitePayload;
checkout: ProjectCheckoutLitePayload | null;
updatedAt: string;
}): WorkspacePlacementUpdate | null {
if (!input.checkout) return null;
const observed = initialWorkspacePlacement({
source: "checkout",
cwd: input.workspace.cwd,
@@ -366,6 +366,7 @@ function createPaseoWorktreeForTest(options: {
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
logger: createLogger(),
});
@@ -25,6 +25,7 @@ const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
interface RunnerCall {
args: string[];
@@ -3324,34 +3325,33 @@ describe("ForgeService", () => {
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify([
{
number: 41,
url: "https://github.com/parentOwner/parentRepo/pull/41",
title: "Wrong fork owner",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "otherFork" },
},
{
number: 42,
url: "https://github.com/parentOwner/parentRepo/pull/42",
title: "Real fork PR",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
},
]),
JSON.stringify([{ number: 41 }, { number: 42 }]),
JSON.stringify({
number: 41,
url: "https://github.com/parentOwner/parentRepo/pull/41",
title: "Wrong fork owner",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "otherFork" },
}),
JSON.stringify({
number: 42,
url: "https://github.com/parentOwner/parentRepo/pull/42",
title: "Real fork PR",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
}),
]);
const service = createGitHubService({
runner: runner.runner,
@@ -3371,27 +3371,182 @@ describe("ForgeService", () => {
title: "Real fork PR",
headRefName: "feature/fork",
});
expect(runner.calls.slice(0, 3).map((call) => call.args)).toEqual([
expect(runner.calls.slice(0, 5).map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"],
[
"pr",
"list",
"--repo",
"parentOwner/parentRepo",
"--state",
"all",
"--head",
"forkOwner:feature/fork",
"--limit",
"10",
"--json",
CURRENT_PR_STATUS_FIELDS,
],
["api", `repos/parentOwner/parentRepo/pulls?${FORK_PR_QUERY}`],
["pr", "view", "41", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
["pr", "view", "42", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
]);
});
it("retries scoped PR list without statusCheckRollup when token permissions are insufficient", async () => {
it("reads the fork PR carrying the checked-out commit past newer attempts on the branch", async () => {
const checkoutSha = "3333333333333333333333333333333333333333";
const viewed: number[] = [];
const runner: GitHubCommandRunner = async (args) => {
if (args[0] === "repo" && args[1] === "view") {
return {
stdout: JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
stderr: "",
};
}
if (args[0] === "api") {
// Newest first, and the checkout is sitting on the oldest attempt.
return {
stdout: JSON.stringify([
{ number: 50, head: { sha: "5".repeat(40) } },
{ number: 49, head: { sha: "4".repeat(40) } },
{ number: 48, head: { sha: "8".repeat(40) } },
{ number: 47, head: { sha: checkoutSha } },
]),
stderr: "",
};
}
if (args[0] === "pr" && args[1] === "view" && /^\d+$/.test(args[2] ?? "")) {
const number = Number(args[2]);
viewed.push(number);
return {
stdout: currentPullRequestJson({
number,
url: `https://github.com/parentOwner/parentRepo/pull/${number}`,
state: "CLOSED",
headRefOid: number === 47 ? checkoutSha : `${number}`.repeat(20),
headRepositoryOwner: { login: "forkOwner" },
}),
stderr: "",
};
}
throw noPullRequestError(args);
};
const service = createGitHubService({
runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: checkoutSha,
});
expect(status).toMatchObject({
number: 47,
repoOwner: "parentOwner",
repoName: "parentRepo",
});
expect(viewed).toContain(47);
expect(viewed).toHaveLength(3);
});
it("propagates a failed fork PR lookup instead of reporting no pull request", async () => {
const rateLimitError = new GitHubCommandError({
args: ["api", "repos/parentOwner/parentRepo/pulls"],
cwd: "/repo",
exitCode: 1,
stderr: "API rate limit exceeded",
});
const runner = createScriptedRunner([
{ error: noPullRequestError() },
JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
{ error: rateLimitError },
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).rejects.toThrow(GitHubCommandError);
});
it("resolves a fork PR whose branch name is crowded in the parent repository", async () => {
// Mirrors what `gh` really does: `pr list --head` matches the string
// against headRefName, so an "owner:branch" head never matches, and a
// popular branch name fills the candidate page with other forks' PRs.
const calls: RunnerCall[] = [];
function parentRepoPullRequest(
number: number,
headRepositoryOwner: string,
): Record<string, unknown> {
return {
number,
url: `https://github.com/parentOwner/parentRepo/pull/${number}`,
title: `PR ${number}`,
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "main",
headRefOid: `${number}`.padStart(40, "0"),
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: headRepositoryOwner },
};
}
const crowd = Array.from({ length: 10 }, (_, index) =>
parentRepoPullRequest(100 + index, `otherFork${index}`),
);
const runner: GitHubCommandRunner = async (args, options) => {
calls.push({ args, cwd: options.cwd });
if (args[0] === "repo" && args[1] === "view") {
return {
stdout: JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
stderr: "",
};
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "4984") {
return { stdout: JSON.stringify(parentRepoPullRequest(4984, "forkOwner")), stderr: "" };
}
if (args[0] === "pr" && args[1] === "view") {
throw noPullRequestError(args);
}
if (args[0] === "pr" && args[1] === "list") {
const headRef = args[args.indexOf("--head") + 1];
const limit = Number(args[args.indexOf("--limit") + 1]);
const matching = crowd.filter((candidate) => candidate.headRefName === headRef);
return { stdout: JSON.stringify(matching.slice(0, limit)), stderr: "" };
}
if (args[0] === "api" && args[1]?.startsWith("repos/")) {
expect(args[1]).toContain("head=forkOwner%3Amain");
return { stdout: JSON.stringify([{ number: 4984 }]), stderr: "" };
}
return { stdout: "{}", stderr: "" };
};
const service = createGitHubService({
runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "main",
});
expect(status).toMatchObject({
number: 4984,
repoOwner: "parentOwner",
repoName: "parentRepo",
headRefName: "main",
});
});
it("retries the fork PR view without statusCheckRollup when token permissions are insufficient", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
number: 7,
@@ -3404,36 +3559,30 @@ describe("ForgeService", () => {
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify([{ number: 42 }]),
{
error: statusCheckRollupPermissionError([
"pr",
"list",
"view",
"42",
"--repo",
"parentOwner/parentRepo",
"--state",
"all",
"--head",
"forkOwner:feature/fork",
"--json",
CURRENT_PR_STATUS_FIELDS,
"--limit",
"10",
]),
},
JSON.stringify([
{
number: 42,
url: "https://github.com/parentOwner/parentRepo/pull/42",
title: "Real fork PR",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
mergedAt: null,
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
},
]),
JSON.stringify({
number: 42,
url: "https://github.com/parentOwner/parentRepo/pull/42",
title: "Real fork PR",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
mergedAt: null,
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
}),
]);
const service = createGitHubService({
runner: runner.runner,
@@ -3454,34 +3603,17 @@ describe("ForgeService", () => {
checks: [],
checksStatus: "none",
});
expect(runner.calls.slice(0, 4).map((call) => call.args)).toEqual([
expect(runner.calls.slice(0, 5).map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"],
["api", `repos/parentOwner/parentRepo/pulls?${FORK_PR_QUERY}`],
["pr", "view", "42", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
[
"pr",
"list",
"view",
"42",
"--repo",
"parentOwner/parentRepo",
"--state",
"all",
"--head",
"forkOwner:feature/fork",
"--limit",
"10",
"--json",
CURRENT_PR_STATUS_FIELDS,
],
[
"pr",
"list",
"--repo",
"parentOwner/parentRepo",
"--state",
"all",
"--head",
"forkOwner:feature/fork",
"--limit",
"10",
"--json",
CURRENT_PR_STATUS_BASE_FIELDS,
],
@@ -3629,21 +3761,20 @@ describe("ForgeService", () => {
name: "repo",
parent: { owner: { login: "parentOwner" }, name: "repo" },
}),
JSON.stringify([
{
number: 88,
url: "https://github.com/parentOwner/repo/pull/88",
title: "Fork-only PR",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: null,
headRepositoryOwner: { login: "forkOwner" },
},
]),
JSON.stringify([{ number: 88 }]),
JSON.stringify({
number: 88,
url: "https://github.com/parentOwner/repo/pull/88",
title: "Fork-only PR",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: null,
headRepositoryOwner: { login: "forkOwner" },
}),
]);
const service = createGitHubService({
runner: runner.runner,
@@ -3662,7 +3793,7 @@ describe("ForgeService", () => {
repoName: "repo",
headRefName: "feature/fork",
});
expect(runner.calls[2]?.args).toContain("forkOwner:feature/fork");
expect(runner.calls[2]?.args).toEqual(["api", `repos/parentOwner/repo/pulls?${FORK_PR_QUERY}`]);
});
it("propagates DNS errors while resolving the current PR view", async () => {
+97 -14
View File
@@ -117,6 +117,11 @@ export const GITHUB_POLL_ALIGNMENT_MS = 5_000;
const GITHUB_GRAPHQL_RESERVE_RATIO = 0.4;
const GITHUB_GRAPHQL_RESET_GRACE_MS = 1_000;
const BATCH_PR_CANDIDATE_LIMIT = 10;
// How many of a fork branch's own pull requests are read in full. One is the
// normal case; the rest are earlier attempts on the same branch, and the one
// carrying the checkout's head commit is read whichever attempt it belongs to.
const FORK_PR_VIEW_LIMIT = 3;
const FORK_PR_PAGE_SIZE = 100;
const FROZEN_PR_CHECKS_CACHE_MAX_ENTRIES = 512;
const GITHUB_ENV = {
GIT_TERMINAL_PROMPT: "0",
@@ -457,6 +462,16 @@ const GitHubRepoViewSchema = z.object({
.optional(),
});
const ForkPullRequestRefsSchema = z.array(
z.object({
number: z.number(),
head: z
.object({ sha: z.string().catch("") })
.nullable()
.optional(),
}),
);
const PullRequestCheckoutTargetSchema = z.object({
data: z.object({
repository: z.object({
@@ -2964,8 +2979,6 @@ async function resolveCurrentPullRequestView(options: {
return viewMatch.status;
}
let listHeadRef = options.headRef;
let listRepo: string | undefined;
let headRepositoryOwner = options.headRepositoryOwner;
if (!headRepositoryOwner) {
@@ -2977,17 +2990,19 @@ async function resolveCurrentPullRequestView(options: {
return null;
}
if (parentOwner && parentName) {
listHeadRef = `${forkOwner}:${options.headRef}`;
listRepo = `${parentOwner}/${parentName}`;
return resolveForkPullRequestView({
...options,
forkOwner,
repo: `${parentOwner}/${parentName}`,
});
}
headRepositoryOwner = forkOwner;
}
const candidates = await listCurrentPullRequestCandidates({
cwd: options.cwd,
headRef: listHeadRef,
headRef: options.headRef,
run: options.run,
repo: listRepo,
});
const match = pickPullRequestCandidate({
candidates,
@@ -2998,6 +3013,77 @@ async function resolveCurrentPullRequestView(options: {
return match?.status ?? null;
}
// A fork checkout's pull request lives in the parent repository, where only
// its head repository tells it apart from every other fork's pull request on
// the same branch name. `gh pr list --head` cannot ask that question: it
// forwards the whole value as a head branch name, so "owner:branch" matches
// nothing (cli/cli#10945), and the bare branch name loses a common name like
// `main` among the other forks crowding the candidate page. The REST pulls
// endpoint is the only lookup that takes a head repository, so it names the
// pull requests and the ordinary view path reads each one's status.
async function resolveForkPullRequestView(options: {
cwd: string;
headRef: string;
headSha?: string;
forkOwner: string;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
const numbers = await listForkPullRequestNumbers(options);
const candidates = await Promise.all(
numbers.map((number) =>
tryCurrentPullRequestView({
cwd: options.cwd,
headRef: options.headRef,
run: options.run,
args: ["pr", "view", String(number), "--repo", options.repo],
}),
),
);
const match = pickPullRequestCandidate({
candidates: candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
),
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.forkOwner,
});
return match?.status ?? null;
}
// The endpoint answers with the fork branch's pull requests, newest first, so
// a branch reused for several attempts can carry the checkout's head commit on
// an older one. Those are read first, and the rest of the window is filled
// newest-first, so the full read stays bounded without dropping the attempt the
// checkout is actually sitting on.
async function listForkPullRequestNumbers(options: {
cwd: string;
headRef: string;
headSha?: string;
forkOwner: string;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<number[]> {
const query = new URLSearchParams({
state: "all",
per_page: String(FORK_PR_PAGE_SIZE),
head: `${options.forkOwner}:${options.headRef}`,
});
const args = ["api", `repos/${options.repo}/pulls?${query.toString()}`];
const stdout = await options.run(args, { cwd: options.cwd });
const refs = parseGitHubJsonOutput(stdout, ForkPullRequestRefsSchema, {
args,
cwd: options.cwd,
emptyFallback: "[]",
});
function carriesHeadSha(ref: (typeof refs)[number]): boolean {
return options.headSha !== undefined && ref.head?.sha === options.headSha;
}
return [...refs.filter(carriesHeadSha), ...refs.filter((ref) => !carriesHeadSha(ref))]
.slice(0, FORK_PR_VIEW_LIMIT)
.map((ref) => ref.number);
}
async function addCurrentPullRequestGithubFacts(options: {
cwd: string;
status: CurrentPullRequestStatus | null;
@@ -3057,16 +3143,18 @@ async function loadPullRequestGithubFacts(options: {
async function tryCurrentPullRequestView(options: {
cwd: string;
headRef: string;
args?: string[];
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<ResolvedPullRequestCandidate | null> {
const args = options.args ?? ["pr", "view"];
try {
const stdout = await runCurrentPullRequestStatusCommand({
cwd: options.cwd,
run: options.run,
args: ["pr", "view"],
args,
});
return parseCurrentPullRequestCandidate(stdout, options.headRef, {
args: ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
args: [...args, "--json", CURRENT_PR_STATUS_FIELDS],
cwd: options.cwd,
});
} catch (error) {
@@ -3081,13 +3169,8 @@ async function listCurrentPullRequestCandidates(options: {
cwd: string;
headRef: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
repo?: string;
}): Promise<ResolvedPullRequestCandidate[]> {
const args = ["pr", "list"];
if (options.repo) {
args.push("--repo", options.repo);
}
args.push("--state", "all", "--head", options.headRef, "--limit", "10");
const args = ["pr", "list", "--state", "all", "--head", options.headRef, "--limit", "10"];
try {
const stdout = await runCurrentPullRequestStatusCommand({
cwd: options.cwd,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"type": "module",
"scripts": {
@@ -44,3 +44,25 @@ export function SlackIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function OpenCollectiveIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 39.98 40.05"
fill="currentColor"
{...props}
>
<path d="M32.76 19.97c0 2.53-.73 4.94-2 6.94l5.14 5.16c2.5-3.36 4.08-7.57 4.08-12.1 0-4.5-1.57-8.7-4.08-12.08l-5.13 5.16c1.26 2 2 4.3 2 6.93z" />
<path d="M20 32.8c-7.02 0-12.78-5.78-12.78-12.83 0-7.04 5.76-12.82 12.77-12.82 2.6 0 4.9.73 6.9 2.1l5.13-5.15C28.68 1.58 24.5 0 20 0 9 0 0 8.94 0 20.08s9 19.97 20 19.97c4.6 0 8.8-1.57 12.14-4.1L27 30.8c-1.98 1.26-4.4 2-7 2z" />
</svg>
);
}
export function BuyMeACoffeeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z" />
</svg>
);
}
@@ -78,6 +78,7 @@ import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import { ClaudeIcon, MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup";
import { FAQItem } from "~/components/faq-item";
import { SiteFooter } from "~/components/site-footer";
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
import { SiteHeader } from "~/components/site-header";
import "~/styles.css";
@@ -123,7 +124,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<AutomationSection />
<ExtensibleSection />
<FAQ />
<SponsorCTA />
<SponsorSection />
<SponsorsSection />
</div>
</main>
<SiteFooter />
@@ -1196,45 +1198,3 @@ function FAQ() {
</motion.div>
);
}
function SponsorCTA() {
return (
<motion.div
initial={FADE_IN_UP}
whileInView={FADE_IN}
viewport={VIEWPORT_60}
transition={EASE_OUT_05}
className="rounded-xl bg-white/5 border border-white/10 p-8 md:p-10 text-left space-y-4 max-w-xl mx-auto"
>
<div className="text-sm text-muted-foreground leading-relaxed space-y-3">
<p>Paseo is an independent open source project for running coding agents.</p>
<p>Its guiding principle is optionality and freedom of choice.</p>
<p>
I wanted to use any provider without being locked into any ecosystem, run it on my own
infrastructure, access it from anywhere, and have it be fully automatable.
</p>
<p>I am hoping that you will enjoy Paseo as much as I do.</p>
<p>If you like Paseo, sponsorship is the best way to support continued development.</p>
<p>- Mo</p>
</div>
<div className="pt-2">
<a
href="/sponsor"
className="inline-flex items-center gap-2 rounded-lg bg-white/10 border border-white/20 px-5 py-2.5 text-sm font-medium text-white hover:bg-white/15 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="text-pink-400"
>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
Sponsor Paseo
</a>
</div>
</motion.div>
);
}
@@ -0,0 +1,290 @@
import { ArrowRight, Check, ExternalLink } from "lucide-react";
import { BuyMeACoffeeIcon, GitHubIcon, OpenCollectiveIcon } from "~/components/brand-icons";
import {
BUY_ME_A_COFFEE_URL,
GITHUB_SPONSORS_URL,
HOMEPAGE_SPONSORS,
HOMEPAGE_SPOT_PRICE,
OPEN_COLLECTIVE_URL,
SPONSOR_CONTACT_EMAIL,
SPONSOR_SPOT_CHECKOUT_URL,
openSpotCount,
} from "~/data/sponsors";
function SectionHeading({
as: Heading,
title,
description,
badge,
}: {
as: "h1" | "h2";
title: string;
description?: string;
badge?: string;
}) {
return (
<div className="mb-12 space-y-2">
<div className="flex items-center gap-3">
<Heading className="text-3xl font-medium tracking-tight">{title}</Heading>
{badge ? (
<span className="rounded-full bg-emerald-400/10 px-2 py-1 text-xs text-emerald-300">
{badge}
</span>
) : null}
</div>
{description ? (
<p className="max-w-lg text-base text-pretty text-muted-foreground">{description}</p>
) : null}
</div>
);
}
const MAINTAINER_LINK = (
<a
href="https://github.com/boudra"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/80"
>
Mo
</a>
);
export function FounderNote() {
return (
<div className="max-w-2xl space-y-5 leading-relaxed text-white/70">
<p>
I build Paseo on my own. There are no investors, no board and no company behind it, and I
have turned down funding offers to keep it that way.
</p>
<p>
A tool that sits between you and your code, your keys and your machines has to stay neutral,
and that gets hard once someone else owns a return on it. Funding creates pressure to
monetize, and that pressure changes what gets built.
</p>
<p>
Paseo is self-funded: the work is paid for by sponsorship and by{" "}
<a href="/hub" className="underline hover:text-white/90">
Paseo Hub
</a>
, an optional hosted service. Your support is what lets me work on Paseo full time.
</p>
<p className="text-white/50">{MAINTAINER_LINK}, maintainer</p>
</div>
);
}
interface BackingOption {
href: string;
name: string;
detail: string;
icon: React.ComponentType<{ className?: string }>;
external: boolean;
primary?: boolean;
}
const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [
{
href: GITHUB_SPONSORS_URL,
name: "GitHub Sponsors",
detail: "From $5 a month, or one time",
icon: GitHubIcon,
external: true,
primary: true,
},
{
href: OPEN_COLLECTIVE_URL,
name: "Open Collective",
detail: "Monthly or one time, with an invoice",
icon: OpenCollectiveIcon,
external: true,
},
{
href: BUY_ME_A_COFFEE_URL,
name: "Buy Me a Coffee",
detail: "One time, no account needed",
icon: BuyMeACoffeeIcon,
external: true,
},
{
href: "/sponsor#spot",
name: "Sponsor a spot",
detail: "For companies",
icon: SpotIcon,
external: false,
},
];
function SpotIcon({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={`inline-block rounded-md border border-dashed border-current ${className ?? ""}`}
/>
);
}
export function BackingOptions() {
return (
<div className="grid gap-3 md:grid-cols-2">
{BACKING_OPTIONS.map((option) => (
<a
key={option.href}
href={option.href}
{...(option.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={`flex items-start gap-4 rounded-xl border p-4 transition-colors ${
option.primary
? "border-white/25 bg-white/[0.06] hover:border-white/40 hover:bg-white/[0.08]"
: "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]"
}`}
>
<option.icon className="mt-0.5 h-6 w-6 shrink-0 text-white/60" />
<div className="space-y-1">
<p className="flex items-center gap-1.5 font-medium text-white">
{option.name}
{option.external ? (
<ExternalLink className="h-3.5 w-3.5 text-white/40" />
) : (
<ArrowRight className="h-3.5 w-3.5 text-white/40" />
)}
</p>
<p className="text-sm text-white/50">{option.detail}</p>
</div>
</a>
))}
</div>
);
}
/** The four homepage spots: sponsor logos first, then a placeholder for each open spot. */
function SponsorLogoRow() {
const placeholders = Array.from({ length: openSpotCount() }, (_, index) => index);
return (
<ul className="grid grid-cols-2 gap-4">
{HOMEPAGE_SPONSORS.map((sponsor) => (
<li key={sponsor.href}>
<a
href={sponsor.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="flex h-32 items-center justify-center rounded-xl border border-white/10 bg-white/[0.02] px-6 transition-colors hover:border-white/20 hover:bg-white/[0.05]"
>
<img src={sponsor.logo} alt={sponsor.name} className="max-h-10 max-w-full opacity-80" />
</a>
</li>
))}
{placeholders.map((index) => (
<li key={index}>
<a
href="/sponsor#spot"
className="flex h-32 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-white/20 transition-colors hover:border-white/40 hover:bg-white/[0.03]"
>
<span className="text-sm font-medium text-white/70">Sponsor</span>
<span className="text-xs text-white/40">{HOMEPAGE_SPOT_PRICE} a month</span>
</a>
</li>
))}
</ul>
);
}
const SPOT_FEATURES: ReadonlyArray<string> = [
"Your logo on the homepage of paseo.sh, around 70,000 visitors a month",
"Your logo in the GitHub README, around 30,000 visitors a month",
"An announcement in the Discord, around 2,000 members",
"A mention in the changelog",
];
export function SponsorSpotSection() {
const open = openSpotCount();
return (
<section id="spot" className="scroll-mt-8">
<SectionHeading
as="h2"
title="Sponsor a spot"
description="Support an independent project and put your company in front of a fast-growing audience of developers."
/>
<div className="space-y-8">
<div className="flex items-end gap-2">
<span className="text-4xl font-medium tracking-tight">{HOMEPAGE_SPOT_PRICE}</span>
<span className="mb-1 text-sm text-white/45">a month</span>
</div>
<ul className="grid max-w-2xl gap-3 text-white/70">
{SPOT_FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<Check aria-hidden="true" className="mt-1 size-4 shrink-0 text-emerald-300" />
<span>{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center">
{open > 0 ? (
<a
href={SPONSOR_SPOT_CHECKOUT_URL}
className="inline-flex items-center rounded-md bg-white px-5 py-2.5 text-sm font-medium text-black transition-colors hover:bg-white/90"
>
Sponsor a spot
</a>
) : null}
<a
href={`mailto:${SPONSOR_CONTACT_EMAIL}`}
className="text-sm text-white/60 underline transition-colors hover:text-white/90"
>
{open > 0 ? "Or email me first" : "Email me to hear when a spot opens"}
</a>
</div>
</div>
</section>
);
}
/** The /sponsor page's first section: the note and the ways to back the work. */
export function SponsorPaseoSection() {
return (
<section>
<SectionHeading as="h1" title="Sponsor Paseo" />
<div className="space-y-10">
<FounderNote />
<BackingOptions />
</div>
</section>
);
}
/** Homepage: a short version of the note with the same links as /sponsor. */
export function SponsorSection() {
return (
<section>
<SectionHeading as="h2" title="Sponsor Paseo" />
<div className="space-y-10">
<div className="max-w-2xl space-y-5 leading-relaxed text-white/70">
<p>
I build Paseo on my own, with no investors and no company behind it, and I have turned
down funding offers to keep it that way. A tool that sits between you and your code,
your keys and your machines has to stay neutral, and funding creates pressure to
monetize.
</p>
<p>
Paseo is self-funded through sponsorship and{" "}
<a href="/hub" className="underline hover:text-white/90">
Paseo Hub
</a>
, and your support is what lets me work on it full time.
</p>
<p className="text-white/50">{MAINTAINER_LINK}, maintainer</p>
</div>
<BackingOptions />
</div>
</section>
);
}
/** Homepage: the four sponsor spots, as they look once filled. */
export function SponsorsSection() {
return (
<section>
<SectionHeading as="h2" title="Sponsors" badge="New" />
<SponsorLogoRow />
</section>
);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Homepage sponsor spots. This file is the inventory: the site shows one open
* placeholder for every spot not listed in `HOMEPAGE_SPONSORS`.
*
* Approving a sponsor means three edits, or the site promises something it does
* not deliver:
*
* 1. Add the entry below and drop its logo in `public/sponsors/`.
* 2. Add the logo under the `## Sponsors` marker in `README.md`,
* `README.zh-CN.md`, `README.ja.md` and `README.ko.md`, in this same order.
* 3. Remove all of it when the subscription ends.
*
* The Stripe payment link caps itself at `HOMEPAGE_SPOT_COUNT` completed
* checkouts, and a checkout you decline and refund still counts against that
* cap. When you refund one, raise the link's completed-session limit by one, or
* the page will offer a spot the link refuses to sell.
*/
export interface HomepageSponsor {
name: string;
href: string;
/** Path under `public/`, e.g. `/sponsors/acme.svg`. Keep logos monochrome white. */
logo: string;
}
export const HOMEPAGE_SPONSORS: ReadonlyArray<HomepageSponsor> = [];
export const HOMEPAGE_SPOT_COUNT = 4;
export const HOMEPAGE_SPOT_PRICE = "$500";
/** Stripe Payment Link for the monthly spot subscription. */
export const SPONSOR_SPOT_CHECKOUT_URL = "https://buy.stripe.com/8x24gBczR7LNaokcve2sM00";
export const SPONSOR_CONTACT_EMAIL = "hello@paseo.sh";
export const GITHUB_SPONSORS_URL = "https://github.com/sponsors/boudra";
export const OPEN_COLLECTIVE_URL =
"https://opencollective.com/paseo-ai/donate?interval=month&amount=10&contributeAs=me";
export const BUY_ME_A_COFFEE_URL = "https://buymeacoffee.com/paseo";
export function openSpotCount(): number {
return Math.max(0, HOMEPAGE_SPOT_COUNT - HOMEPAGE_SPONSORS.length);
}
+5 -114
View File
@@ -1,12 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { SiteShell } from "~/components/site-shell";
import { SponsorPaseoSection, SponsorSpotSection } from "~/components/sponsorship";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/sponsor")({
head: () =>
pageMeta(
"Sponsor Paseo",
"Support the independent open-source project behind Paseo. Built by Mo, funded by the community.",
"Paseo is built by one person with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or put your company's logo on the homepage.",
"/sponsor",
),
component: Sponsor,
@@ -15,120 +16,10 @@ export const Route = createFileRoute("/sponsor")({
function Sponsor() {
return (
<SiteShell width="default">
<h1 className="text-3xl font-medium tracking-tight mb-8">Sponsor</h1>
{/* Founder note */}
<div className="space-y-6 text-white/70 leading-relaxed max-w-2xl">
<p className="font-medium">
Hey, I&apos;m{" "}
<a
href="https://github.com/boudra"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/80"
>
Mo
</a>
.
</p>
<p>
Paseo started as a personal project: I wanted to run coding agents from anywhere without
giving up control of my code, my keys, or my workflow.
</p>
<p>
I work on Paseo full-time. It&apos;s an independent, self-funded open-source project. I
don&apos;t have investors, a board, or a big team. I&apos;m just shipping software the
best I know how and hope it&apos;s useful to other people.
</p>
<p>
Sponsorship is what makes that sustainable. It lets me stay focused on the product instead
of chasing monetization, and it sends a clear signal that independent, open-source tools
have a place in developer workflows.
</p>
<p>Thank you.</p>
<div className="space-y-24">
<SponsorPaseoSection />
<SponsorSpotSection />
</div>
{/* Sponsor links */}
<section className="mt-12 space-y-4">
<h2 className="text-xl font-medium">Support Paseo</h2>
<div className="flex flex-col sm:flex-row gap-4">
<a
href="https://github.com/sponsors/boudra"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="currentColor"
className="text-white/60"
>
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
</svg>
<div>
<p className="font-medium text-white">GitHub Sponsors</p>
</div>
</a>
<a
href="https://opencollective.com/paseo-ai/donate?interval=month&amount=10&contributeAs=me"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 39.98 40.05"
className="text-white/60"
>
<path
d="M32.76 19.97c0 2.53-.73 4.94-2 6.94l5.14 5.16c2.5-3.36 4.08-7.57 4.08-12.1 0-4.5-1.57-8.7-4.08-12.08l-5.13 5.16c1.26 2 2 4.3 2 6.93z"
fill="currentColor"
/>
<path
d="M20 32.8c-7.02 0-12.78-5.78-12.78-12.83 0-7.04 5.76-12.82 12.77-12.82 2.6 0 4.9.73 6.9 2.1l5.13-5.15C28.68 1.58 24.5 0 20 0 9 0 0 8.94 0 20.08s9 19.97 20 19.97c4.6 0 8.8-1.57 12.14-4.1L27 30.8c-1.98 1.26-4.4 2-7 2z"
fill="currentColor"
/>
</svg>
<div>
<p className="font-medium text-white">Open Collective</p>
</div>
</a>
<a
href="https://buymeacoffee.com/paseo"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
className="text-white/60"
>
<path
d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"
fill="currentColor"
/>
</svg>
<div>
<p className="font-medium text-white">Buy Me a Coffee</p>
</div>
</a>
</div>
</section>
</SiteShell>
);
}