Compare commits

..
62 Commits
Author SHA1 Message Date
Mohamed Boudra c67b7158b4 chore(release): cut 0.9.2 2026-09-24 12:05:08 +02:00
Mohamed Boudra 91dc927335 docs: add 0.9.2 changelog 2026-09-24 12:01:57 +02:00
paseo-bot[bot] 49f9cec6be Name config.json when the CLI cannot parse it (#5337)
readPersistedConfig, used by paseo daemon config, status, pair, and
set-password, parsed and validated config.json without the error
wrapping loadPersistedConfig applies, so an invalid file surfaced as a
bare SyntaxError or ZodError. Both readers now share one parse step
that names the file and, for schema errors, the field.

Closes #5328

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 10:47:49 +02:00
paseo-bot[bot] e07da55f81 Show Open in editor for a password-protected desktop daemon (#5335)
* fix(daemon): report the local daemon's server id when a password is set

Since #4575, `paseo daemon status` only learned the server id from an
authenticated connection. The desktop app runs that command without the
daemon password, so a password-protected desktop daemon reported no server
id, the app treated it as remote, and hid Open in editor.

The worker now announces its server id with readiness and the supervisor
publishes it in the instance record next to the listen address, so status
reports the running daemon's identity from local state.

Closes #5300

* fix(cli): omit serverId from status until a daemon publishes one

* chore: rerun CI after macOS file observation flake

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 10:00:01 +02:00
paseo-bot[bot] 27d6a71856 fix(daemon): name the cause when a background start fails before logging (#5332)
`paseo daemon start` runs the supervisor with its output discarded, and the
supervisor opens its log only after config.json loads and the PID lock is
held. A failure before that point went to stderr only, so the launcher
pointed at a daemon.log that was never written and showed no cause.

Startup failures now also go to the log path the launcher reports, so the
error message tails the real cause.

Closes #5327

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 09:00:48 +02:00
paseo-bot[bot]andNoah bbf8cce3f7 List the Fable model mapped in Claude settings.json (#5326)
* fix(server): discover ANTHROPIC_DEFAULT_FABLE_MODEL from Claude settings

* Retrigger CI after website dev server startup timeout

* test(server): assert the built-in Fable entry through the fetched catalog

---------

Co-authored-by: Noah <noahguerin20@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 07:55:45 +02:00
paseo-bot[bot] 6016ed7059 Reject missing and non-directory paths when an agent creates a local workspace (#5322)
* Reject missing and non-directory paths for local create_workspace

The create_workspace MCP tool with isolation local persisted a workspace
and project for a path that does not exist or is a regular file. The
app's workspace create request already rejects those paths with
"Directory not found"; the tool now applies the same check before
provisioning.

* Make the local create_workspace e2e assertions path-format independent

* Rethrow filesystem errors other than a missing path in local create_workspace

* Express the local create_workspace e2e test through daemon and workspace helpers

* Clean up the offline MCP daemon when its startup fails

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 06:50:41 +02:00
paseo-bot[bot]andJared 8e858f0e35 Keep checked options and the Other answer together on multi-select questions (#5320)
* Keep checked options and the Other answer together on multi-select questions

On a multi-select question card, typing into Other cleared the checked
options, and checking an option after typing dropped the Other text from
state while the field kept showing it, so submit sent the labels alone.

Multi-select now keeps both and submits the checked labels followed by
the Other text, joined with ", ". Single-select still replaces one with
the other, and now clears the Other field on screen when it does.

Closes #4370

Co-authored-by: Jared <77949461+ThePharmer@users.noreply.github.com>

* Query the question card test by role and accessible name

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Jared <77949461+ThePharmer@users.noreply.github.com>
2026-09-24 06:02:37 +02:00
paseo-bot[bot] fbc83613c0 Keep original file names for chat uploads (#5317)
* fix(server): keep original file names for chat uploads

Uploads replaced every character outside ASCII letters, digits, dot,
dash, and space with an underscore, so CJK, Korean, accented, and
parenthesized names reached disk and the agent as underscores.
Replace only characters no file system accepts in a file name, and
shorten names past 255 bytes while keeping the extension.

* test(server): make upload separator test independent of the daemon OS

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 05:01:09 +02:00
paseo-bot[bot] c356394bfa Load config.json saved with a UTF-8 byte order mark (#5315)
* Load config.json saved with a UTF-8 byte order mark

Windows Notepad saves UTF-8 with a BOM. Both config readers passed the
text straight to JSON.parse, so the daemon refused to start and CLI
config commands failed with "Unexpected token".

Closes #4683

* Strip the config BOM with an anchored replace

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 03:48:52 +02:00
paseo-bot[bot] e6085c1e98 Point CLI auth failures at PASEO_PASSWORD instead of daemon start (#5310)
A password-protected daemon that rejects the CLI is running, so the
start hint was the wrong remedy for AUTH_REQUIRED and AUTH_FAILED.

Fixes #4508

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 02:53:02 +02:00
paseo-bot[bot] 1bf5312297 Start the daemon when an empty paseo.pid was left behind (#5306)
A supervisor killed between creating paseo.pid and writing its contents
leaves an empty file. Every later start, status, and stop then failed
with "Cannot read daemon state", so the daemon stayed down until someone
deleted the file by hand.

An empty lock names no owner, so after the existing read retries it now
reads as no lock, and acquiring the lock removes it before creating a new
one. A lock with unparseable contents still refuses, since it may belong
to a supervisor this version cannot read.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 01:53:13 +02:00
paseo-bot[bot] 7fa78244fd fix(cli): emit full permission request ids from permit ls, allow, and deny (#5305)
The permit result items stored the request id already shortened to eight
characters for the table, so JSON, YAML, and quiet output serialized that
display value. Every request id starts with "permission-", so every row
came out as "permissi" and could not be passed back to permit allow.
The items now keep the full id and only the table column shortens it.

Closes #4732

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 00:52:03 +02:00
paseo-bot[bot] f4efdbded7 Start the daemon when the schedules directory holds a file that is not a schedule (#5301)
* Start the daemon when the schedules directory holds a file that is not a schedule

One unreadable .json file in the schedules directory rejected
ScheduleStore.list(), which ScheduleService.start() awaits during daemon
bootstrap, so the daemon never started. The store now skips a file it
cannot read as a schedule and logs its path once, the way agent storage
treats an invalid agent record.

Closes #4745

* Skip only schedule files with invalid content

A schedule file that cannot be read now fails the listing as before,
instead of being skipped as if it were not a schedule. An invalid file
is reported again when it reappears after being removed or fixed.

* Retrigger CI after an unrelated macOS observation-test flake

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 23:49:18 +02:00
Mohamed Boudra c976e2e5a5 test(server): wait for repository observation before asserting ref refresh (#5303)
* test(server): wait for repository observation before asserting ref refresh

The first checkout facts read can begin before the metadata watcher registers. The test sent an event through an optional watcher lookup at that point and silently dropped it, so a slow setup left only the initial snapshot. Hold the first read to reproduce the ordering and wait for observation setup and the initial refresh before emitting the ref event.

* test(server): isolate ref refresh from background fetch

An asynchronous background fetch could also schedule a structural refresh or populate the known-ref set. Give this test an empty fetch result and wait for that fetch to complete so the second facts read proves the injected watcher event caused it.

* test(server): require the registered repository watcher in the assertion
2026-09-23 23:25:30 +02:00
paseo-bot[bot]andMohamed Boudra b2bb512b31 Let a plugin reload finish without crashing the plugin subprocess (#5231)
* Keep the plugin IPC channel open until provider closes report

closeProviderConnection removed the connection from the registry before
awaiting the provider's close, so shutdown's sweep could not see a close
already in flight. With a provider whose close does real work, the
subprocess finished shutting down and called process.disconnect() while
that close was still running, and the trailing provider.closed send hit a
closed channel and took the subprocess down with an unhandled
ERR_IPC_CHANNEL_CLOSED.

Keep the connection registered until its close has reported, and share
one close promise per connection, so shutdown waits for an in-flight
close instead of disconnecting underneath it. The close now also owns
reporting its own outcome, so it never rejects into teardown.

* Harden the reload teardown test for Windows and clean shutdown

Normalize line endings before patching the example provider, so the
fixture also builds from a CRLF checkout. Wait for the in-flight turn and
archive the agent before stopping the daemon, so teardown no longer
rejects a pending provider request into an unhandled rejection.

* Verify plugin reload teardown and reject sends during close

* Synchronize plugin reload teardown test with close lifecycle

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-23 23:24:53 +02:00
Mohamed Boudra 067937bace Let Hub title workspaces created by its executions (#5302)
* fix(server): let Hub title created workspaces

Agent creation can provision a workspace under hub.execute, but a separate title request required workspace.manage. Permit the same execution authority for title changes and their responses, and lock the Hub request contract with socket and permission tests.

* test(server): keep Hub title repro free of conditional flow
2026-09-23 23:15:06 +02:00
paseo-bot[bot] d615d3d424 Cut the cost of a directory-suggestion scan so Add Project search stops timing out (#5190)
* fix(server): stop re-deriving path state for every scanned directory

Directory suggestion scans were CPU-bound in path bookkeeping rather than
in reading the tree. A 20,000-entry scan of a 197,000-directory home took
6.9s, of which readdir accounted for 50ms; the rest went to repeated path
normalization.

Three redundant passes are removed:

- Discovery re-derived Git-ignore state by walking every ancestor of every
  entry. Traversal only descends through entries that already passed that
  check, so only the entry itself can be newly ignored.
- Discovery re-derived root containment for every child. Only a symlink can
  resolve outside the tree being walked; every other child is its parent's
  path plus a name, and the parent was already proved inside.
- The directory listing cache swept itself for expired keys on every miss
  once it reached its cap, an O(cache size) pass per directory read. Reads
  already reject stale entries by expiry and by directory metadata.

The same scan now takes 1.1s. The new test expresses the budget against a
plain readdir walk of the same tree, so it means the same thing on CI as on
a developer machine: the ratio was 12x and is now under 3x.

Refs #4879

* test(server): skip the scan-cost budget on Windows

The tree it needs is deeper than MAX_PATH allows, and the directory
listing cache it measures is disabled on Windows because directory
metadata cannot validate it there.

* test(server): count containment derivations instead of timing the scan

The previous budget compared wall-clock scan time against a readdir walk
of the same tree. packages/server/AGENTS.md requires tests to be
deterministic, and a ratio between an I/O-bound walk and a CPU-bound scan
moves with filesystem caching and runner load.

isPathInsideRoot now counts its calls behind start/stopPathContainmentMetrics,
matching the existing git command metrics. Scanning a symlink-free tree of
320 entries derived containment 1,760 times before this branch and derives
it none now, which is the defect stated exactly rather than inferred from a
stopwatch.

A second test covers the other direction: a symlink that leaves the root is
still checked, and still excluded from results.

* fix(server): keep the Git-ignore ancestor walk for symlinked entries

A symlink resolves to a path whose ancestors were never checked during
traversal, so a link into an ignored directory was discovered. Named
children keep the single-entry check.

* test(server): declare the scan helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:55:13 +02:00
paseo-bot[bot] fbe5005aa1 Allow Backspace in custom keyboard shortcuts (#5224)
Capture modified Backspace shortcuts in the editor while keeping bare Backspace as the erase-last-combo key. Cover the reported Mac chord, its Windows Ctrl equivalent, and the erase behavior in the desktop renderer suite.

Closes #5215

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:39:00 +02:00
Mohamed Boudra e998e0a082 Stop the daemon holding a CPU core on a repository the watcher can't observe (#5170)
* fix(server): back off degraded git polling on an unchanged workspace

When the recursive watcher declines a repository (over 250,000 entries on
macOS/Windows, or any watcher setup failure), WorkspaceGit falls back to
polling. That fallback rescheduled itself every 5 seconds for as long as the
workspace existed, with no client or agent involved. On a repository large
enough to defeat the watcher each tick is an expensive `git status`, so the
daemon held about a core indefinitely.

Double the gap after every tick whose workspace snapshot came back identical,
up to a 60-second ceiling, and reset to the 5-second base on the first change.
Both degraded loops — working tree and repository metadata — share the rule.
Explicit reads still force a refresh, so the ceiling only bounds how long an
edit made outside Paseo stays undiscovered.

Fixes #5102

* test(server): cover repository-metadata degraded backoff, document failed-tick semantics

* test(server): drive the recovery-refresh assertion off the fake clock, not waitFor

* test(server): declare metadata poll counter as a function
2026-09-23 22:20:17 +02:00
paseo-bot[bot] ae42b0afb0 fix(omp): wait for terminal event after custom messages (#3258)
Custom messages may precede the prompt user message during an active OMP turn. Treating them as completion terminalized the turn while the provider continued working.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:06:53 +02:00
paseo-bot[bot] 829cc5e173 Let Claude sessions pass structured launch arguments (#5206)
Expose Claude Agent SDK's documented extraArgs map through providerOptions so Claude-specific flags such as --chrome reach the CLI.

Document boolean and valued flags, link the upstream contract, and validate through schema tests, the public session interface, and a real Claude launch.
2026-09-23 22:04:20 +02:00
paseo-bot[bot] 48384cafdf Create new branches without an upstream so the first push cannot land on main (#5249)
* fix(server): create new branches without an upstream

A new branch Paseo creates in place ran `git checkout -b <new> <base>`,
which lets git decide the upstream. Git sets `origin/<base>` whenever the
base is a remote-tracking ref (what the default-branch resolver returns
when the local default branch is absent) or when `branch.autoSetupMerge`
is `inherit`. Every later push reads that upstream, so the work lands on
the default branch: `git push -u origin <branch>` under
`push.default=tracking` pushes to `main`, and Paseo's own push resolves
`branch.<name>.merge` and pushes `HEAD:refs/heads/main`.

Pass `--no-track`, the same promise the worktree path already makes with
`git worktree add -b <branch> --no-track <base>`.

* test(server): read the upstream with for-each-ref so a git failure cannot read as absent

* test(server): use a function declaration for the clone fixture git runner

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:03:55 +02:00
paseo-bot[bot]andGurvan Campion 2c7b38bc7e Run OpenCode agents under the permission rules they were given (#5296)
* fix(opencode): set permission rules on the OpenCode session

OpenCode stores permission rules on the session and its prompt_async
request schema has no permission field, so rules built from provider
options and Hub tool grants were dropped on every turn: an agent
configured with external_directory: allow still asked before reading
outside its worktree.

Send the rules where OpenCode reads them — on session.create, and on
session.update when resuming a session Paseo did not just create — and
stop attaching them to prompts. A resume that cannot apply its rules now
fails instead of running under a more permissive policy than configured.

The real-server test is taken from Gurvan Campion's #5295, which reached
the same diagnosis independently.

Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>

* test(opencode): use function declarations for the permission test helpers

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>
2026-09-23 21:56:19 +02:00
Mohamed Boudra 222d45a2fc fix(plugin-provider): keep request failures from crashing the daemon (#5298)
Observe provider response rejection before transport acceptance completes, while preserving the original promise and errors returned to callers. Cover early failure, send rejection, closure, cancellation, and successful retries through the public provider interface.

Closes #4781.
2026-09-23 21:54:46 +02:00
Mohamed Boudra 373da70694 Present company sponsorship as support instead of ad space (#5297)
* feat(website): present company sponsorship as support, not ad space

The homepage showed four empty "Sponsor / $500 a month" boxes under a
"New" badge, right below a note about staying independent. They read as
ad inventory for sale. The price now lives only on /sponsor, next to the
checkout. The homepage shows a Sponsors row only once a company has
sponsored, and the READMEs link to /sponsor instead of straight to
Stripe checkout.

* feat(website): drop subtitles from the sponsor options

Each option carried a line like "From $5 a month" or "with an invoice" that tried to set it apart, but the differences are not real: GitHub Sponsors invoices too and any channel takes small amounts. Showing the names lets people pick the platform they already use.
2026-09-23 21:25:09 +02:00
Mohamed BoudraandMarek Rom c3e1e084a0 fix(server): bound watcher work for late ignored directories
Refresh Git exclusions when ignored directories appear after subscription and bound Linux and native classification work. Preserve tracked-file notifications, ignore-update barriers, and watcher recovery.

Fixes #4558. Supersedes #4808 by @Marcus172; this change incorporates six commits originally authored by Marek Rom and adds verified follow-up fixes. The independent session-context leak is fixed by #5208. Combined isolated daemon verification passed.

Co-authored-by: Marek Rom <marek.marcus.rom@gmail.com>
2026-09-23 20:55:30 +02:00
Mohamed Boudra 9978988e35 fix(server): prevent daemon heap growth after sessions close
Keep AsyncLocalStorage registrations fixed across SessionDelivery lifetimes, preserving request isolation and nonblocking close.\n\nVerified by failing-first regression, isolated memory reproduction, and combined daemon OOM verification with #5222.
2026-09-23 20:54:51 +02:00
paseo-bot[bot] 8768500fc0 Stop a replica cache read from spinning when the store rejects its writes (#5290)
* Stop a replica cache read from spinning when the store rejects its writes

A replica cache read waits for accepted commits to reach the row store
before it answers, and retries until the store agrees. When the store
rejects the write deterministically, that retry never makes progress: the
read loops on flush and read with no delay, burning a core and never
returning. The read now fails closed on a rejected write, so callers fall
back to the daemon instead of the loop saturating the renderer.

* Fail a read closed only when its own host has unwritten changes

A rejected write carries whatever was pending across every host, so
giving up the read on any rejection hid stored rows from hosts that had
nothing pending. A host whose rows are already in the store stays
readable while another host's write keeps failing.

* Bound the read count in the storage fake instead of the test body

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 20:45:06 +02:00
paseo-bot[bot] 89073d4c5c Keep a Claude rewind anchor off subagent messages (#5289)
Task subagent frames stream on the same query as the conversation, and
rememberTranscriptProgress recorded any assistant frame as the current turn's
rewind anchor. When a turn's last assistant frame came from a subagent, the
anchor held a uuid that lives on the subagent's sidechain rather than on the
session's own chain, so forkSession rejected it with "Message <uuid> not found
in session" and every later rewind in that conversation failed.

Skip frames that carry a parent_tool_use_id when recording anchors. The
persisted history path already skips sidechain entries; the live stream now
applies the same rule.

Closes #5284

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 19:45:11 +02:00
paseo-bot[bot] ec43e90670 Let a rebound pane-focus shortcut fire while typing (#5287)
The pane-focus defaults carry `editable: false` so that Cmd+Shift+Arrow
keeps selecting text in a field instead of moving pane focus. That guard
describes the default combo, not the action, but `buildEffectiveBindings`
carried it onto a user's override: rebinding focus-pane to Alt+H/J/K/L in
Settings produced a shortcut that fired everywhere except the composer,
and since switching panes focuses that pane's composer, navigation
stopped after one hop.

Drop the guard when an override replaces the combo, the same way the
default display keys are dropped. The default Cmd+Shift+Arrow still
yields to text selection.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 18:46:26 +02:00
paseo-bot[bot] 897a0abb1a Keep one copy of the timeline when an agent is refreshed (#5286)
* Clear the committed timeline when an agent refresh rehydrates from disk

Refreshing a loaded agent reloads it with `rehydrateFromDisk`, which wipes
the in-memory timeline so provider history can be re-streamed. It left the
committed timeline in place, and that is what `getTimelineRows` reads, so
every refresh appended a second full copy of the history: N refreshes, N
copies of every message.

Wipe both timelines on that path, the way the force-hydrate path already
does, so the re-streamed history replaces the old rows instead of stacking
on them.

* Replace the committed timeline from the replay instead of wiping it first

Deleting the committed rows on the reload path meant a history replay that
failed halfway left the agent with no transcript, and a retry appended a
fresh replay to whatever partial rows it had written.

Collect the whole replay before touching either store, then drop the rows a
previous hydration committed and write the new ones. A failed stream now
leaves the committed timeline exactly as it was, and this covers the replay
that runs when an agent is loaded after a daemon restart, which duplicated
the same way a refresh did.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 18:46:20 +02:00
paseo-bot[bot] c4771ca467 Rewind a Claude conversation past a turn that got no response (#5285)
A turn that dies before the model emits its first token leaves its rewind
anchor without an assistant message id. resolveConversationRewindTarget read
only the anchor immediately before the target, so that one turn made every
later rewind in the conversation throw "assistant response id was not
observed" for good.

Such a turn carries no conversation state, so walk back to the most recent
turn that did answer and fork there, falling back to a fresh session when
none did.

The rewind SDK now reaches the session as an option so the regression test
can drive revertConversation without forking a real Claude session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 17:46:00 +02:00
paseo-bot[bot] 05bfffad41 Keep the voice thinking tone out of the gaps inside a spoken reply (#5281)
Closes #5061
2026-09-23 16:49:10 +02:00
paseo-bot[bot] faee1cd952 Start the daemon after a reboot hands its old PID to another process (#5277)
Closes #5257
2026-09-23 16:49:05 +02:00
paseo-bot[bot]andgengjiawen ba4595d4e8 Start a Cursor agent on a model that has no Fast variant (#5274)
* Start an ACP session when a stored feature value no longer applies

Replaying stored config onto a new ACP session already tolerates a model
the session cannot select, warning and keeping the provider default.
Feature values were the exception: they were written unconditionally, and
either Paseo's own "does not expose ACP feature" guard or the provider's
-32602 rejection escaped out of initializeNewSession, so no agent was
created.

Cursor's `fast` is a per-model option, so switching a draft from a
fast-capable model to one without a fast variant left a stored value the
new session cannot take, and agent creation failed.

Apply stored feature values best-effort at session start, matching the
model path. A user-initiated setFeature still reports the failure.

Co-authored-by: gengjiawen <technicalcute@gmail.com>

* Tolerate a provider's invalid-params only after a model switch

A stale option snapshot is only possible when the replay switched the
model, since the model write answers with an empty response. Without a
switch the options came straight from session/new, so a rejected write
is a real failure the user needs to see.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: gengjiawen <technicalcute@gmail.com>
2026-09-23 15:45:26 +02:00
34c9fd03c9 fix(codex): expose Fast for GPT-6 Sol and GPT-6 Luna (#5273)
The Codex Fast allowlist covered only gpt-6-astra in the GPT-6 family, so
selecting GPT-6 Sol or GPT-6 Luna published no fast_mode feature and the
composer had no Fast control to render. OpenAI documents Fast for Astra,
Sol, and Luna, and the app server's model/list advertises the fast speed
tier for all three.

Add both model IDs to the allowlist and to the parameterized feature test
covering the toggle, the restore path, and the serviceTier sent on turn
start.

The same change was proposed independently in #5220 and #5268.

Closes #5271

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: basilkhowaja <bk08432@st.habib.edu.pk>
2026-09-23 14:46:37 +02:00
paseo-bot[bot] 9a3f3a0dc6 fix(app): complete a multi-step shortcut whose second step holds a modifier (#5272)
A recorded sequence like Ctrl+K Ctrl+J never fired. Pressing a modifier
emits a keydown for the modifier key itself before the combo that holds
it, so a chord waiting on its second step saw a bare `Control` first.
That keydown matches no combo, and the resolver's fall-through reset the
chord to step 0, leaving the following Ctrl+J to resolve as a first step
and match nothing. A second step with no modifier was unaffected, which
is why Ctrl+K J worked.

A modifier keydown is the prefix of a combo, never a combo of its own, so
the resolver now ignores it and leaves the chord where it is. The capture
path already held that rule in `keyboardEventToComboString`; this exports
the predicate behind it rather than adding a second notion of what a
modifier key is.

Closes #5265
2026-09-23 14:46:33 +02:00
paseo-bot[bot]andIvan Malison d6861f81e5 Keep a multi-key shortcut alive when the app re-renders between its keys (#5255)
### Linked issue

Closes #4821

### Type of change

- [x] Bug fix

### Reasoning

A key sequence recorded in Settings, for example `Ctrl+K` then `J`, silently did nothing whenever the app re-rendered between the two keys. Resizing the window, toggling the sidebar, or an agent turn starting or finishing is enough, so a rebound sequence looks broken at random and the second key lands in the composer as text.

The global `keydown`/`keyup`/`blur`/`visibilitychange` listeners, and on desktop the `browser-shortcut-input` subscription, were registered by an effect that depended on eighteen render-scoped values. One of them, `useActiveWorkspaceSelection()`, returns a new object every render, so the effect tore down and re-subscribed on every render of `AppContainer`. Its cleanup resets the in-progress sequence, so the second key was resolved from step 0, matched nothing, and fell through as ordinary input.

The key handlers now read the latest render through `useStableEvent`, so the listener effect re-subscribes only when shortcuts are enabled or disabled. Clearing held badge modifiers when the workspace-index jump binding changes moved to its own effect keyed on that modifier. The cleanup still resets the sequence, which is now correct, because it only runs when shortcuts genuinely go away.

### Goals

- A recorded key sequence completes across a root re-render between its keys.
- The global shortcut listeners are registered once while shortcuts are enabled, instead of on every render.
- A regression spec that fails on the current `main` and passes with this change.

### Non-goals

- `useActiveWorkspaceSelection()` still returns a new object per render. This PR stops the shortcut listeners from depending on that identity, it does not change the hook.
- A sequence whose second step also carries a modifier (`Ctrl+K` `Ctrl+J`) is still cancelled when the modifier is re-pressed, because the bare `Ctrl` keydown does not match the next step. That is a separate defect and is not fixed here.
- The Electron `browser-shortcut-input` path is unchanged in shape and was not exercised in a desktop build.

### QA

New spec `packages/app/e2e/browser/keyboard-shortcut-sequence.spec.ts`. It seeds `Ctrl+K J` as the "Toggle command center" binding, opens a workspace, and presses the sequence twice: once with nothing in between, once with a viewport resize settled over two frames between the keys.

Before, on `main`, run twice:

```
✘ multi-key shortcut sequences › a sequence completes when the window resizes between its keys
  expect(getByTestId('command-center-panel')).toBeVisible() failed
```

The control assertion in the same test passed both times, so the sequence itself works and only the re-render breaks it. Instrumenting the hook locally showed the mechanism directly:

```
key Control -> chord step 0
key K       -> chord step 1
effect cleanup at chord step 1      <- the resize re-render
effect setup; changed deps: activeWorkspaceSelection
key j       -> chord step 0, match null
```

After, the same spec passes, and `activeWorkspaceSelection` was the only dependency that changed on those renders.

Also run locally on this branch:

- `packages/app` unit suite: 603 files, 5319 tests, all passing.
- Playwright `browser` project, keyboard and command-center specs: `keyboard-shortcut-sequence`, `empty-pane-keyboard`, `changes-shortcut-placement`, `sidebar-workspace-pin-shortcut`, `workspace-archive-shortcut`, `command-center-agent-controls`. 17 passed.
- `npm run typecheck --workspace=@getpaseo/app`, `oxlint`, and `oxfmt --check` on the changed files: clean.

Web only, in Chromium on Linux. Keyboard shortcuts are off on mobile, and the desktop build was not exercised.

### Supersedes

- [PR #4834 — fix(app): keep multi-key shortcuts alive across root re-renders](https://github.com/getpaseo/paseo/pull/4834) — same fix for the same bug. I diagnosed #4821 independently, reached the same cause, and built on that commit, so its author is credited with a `Co-authored-by` trailer. This PR replaces its spec with one that drives the app through the keyboard only, without patching `window.addEventListener` to count registrations.

### Checklist

- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-09-23 12:47:26 +02:00
paseo-bot[bot]andMatteo Pietro Dazzi 3054f80050 Show MiniMax usage as unavailable when the account has no token plan (#5258)
### Linked issue

Closes #5247

### Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs

### Reasoning

A user whose MiniMax account has no active token plan opened the Usage page and saw a red **Error** badge on the MiniMax card with a block of raw JSON as its body: `[{"expected":"array","code":"invalid_type","path":["model_remains"],...}]`.

MiniMax reports application-level failures inside an HTTP 200 body — `base_resp.status_code` is non-zero and `model_remains` comes back as `null`. Two things went wrong with that response:

- `MiniMaxQuotaResponseSchema` declared `model_remains` as `z.array(...).optional()`, which admits a *missing* key but not `null`, so `.parse()` threw on a perfectly ordinary no-subscription response.
- `fetchUsage()` only checked `res.ok`, so an API-level error returned with HTTP 200 was never inspected in the first place.

The rejection propagated to `ProviderUsageService`, which stores `reason.message` as the card's error text. For a `ZodError`, that message *is* the serialized issue list — which is how the JSON blob reached the card.

This accepts `null` for `model_remains`, parses `base_resp`, and returns the plain unavailable result when the status code is non-zero, matching how a failed HTTP response is already handled in the same method. The API's own `status_msg` goes to the debug log rather than to the card: an account without a token plan is a normal state, not an error, so it should read as "Unavailable" like an unconfigured provider does — not as a red Error.

### Goals

- A MiniMax account with no active token plan renders as **Unavailable** with no error text.
- `model_remains: null` is accepted wherever it appears, with or without a `base_resp`.
- Application-level errors carried in an HTTP 200 body are detected rather than treated as success.
- Accounts that do have a plan keep rendering their interval and weekly windows unchanged.

### Non-goals

- **Generic error rendering in the Usage card.** The issue also asks that structured errors (ZodError and friends) be mapped to a concise message in the UI. That changes what every provider's card shows on schema drift, so it is a design decision for the maintainer rather than part of this fix. With the producer corrected, no ZodError arises on this path; if another provider's response shape drifts, raw issue JSON can still reach the card. Left open for the maintainer.
- Surfacing MiniMax's `status_msg` in the UI. It is logged at debug instead, so the card does not turn red for a normal no-subscription account.

### QA

Reproduced and verified through the real `ProviderUsageService` with the exact payload from the report, in `packages/server/src/services/quota-fetcher/service.test.ts`.

**Before** — `reports MiniMax accounts with no active token plan as unavailable` fails, and the failure output is the reported symptom verbatim:

```
- Expected                          + Received
-   "status": "unavailable",        +   "status": "error",
-   "error": null,                  +   "error": "[
                                    +   {
                                    +     \"expected\": \"array\",
                                    +     \"code\": \"invalid_type\",
                                    +     \"path\": [ \"model_remains\" ],
                                    +     \"message\": \"Invalid input: expected array, received null\"
                                    +   }
                                    + ]",
```

**After** — the full quota-fetcher suite passes: `Tests 71 passed (71)`.

Three tests were added, two of which are red without the provider change (confirmed by stashing only `minimax.ts`):

- `reports MiniMax accounts with no active token plan as unavailable` — the reported response (`model_remains: null` + `base_resp` 2062) → `status: "unavailable"`, `error: null`.
- `reports a null MiniMax token plan as unavailable even without base_resp` — `model_remains: null` alone.
- `still reads MiniMax windows when base_resp reports success` — guards against the new status check rejecting healthy accounts.

Also run locally: `npm run typecheck --workspace=@getpaseo/server`, `npx oxlint packages/server/src/services/quota-fetcher/`, `npx oxfmt --check packages/server/src/services/quota-fetcher/` — all clean.

No UI change, so no screenshots: with the provider fixed, the card takes its existing "Unavailable" rendering, the same one an unconfigured provider already gets.

### Checklist

- [x] Plugin changes follow the SDK import boundaries (if applicable)
- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

## Supersedes

- [PR #5252 — fix(quota): handle MiniMax accounts with no active token plan (#5247)](https://github.com/getpaseo/paseo/pull/5252) — Same diagnosis and same reported workflow. That PR returns `status: "error"` carrying MiniMax's `status_msg`, which keeps the red Error badge on the card for an account that is simply unsubscribed; this branch returns the plain unavailable result the issue asks for. It also adds cross-provider `ZodError` summarizing in `ProviderUsageService`, which is the design decision left as a non-goal above.

Co-authored-by: Matteo Pietro Dazzi <ilteoood@gmail.com>
2026-09-23 12:46:21 +02:00
paseo-bot[bot] 6d37f7fd9f Stop plugins after the agents they serve are closed (#5253)
* Close agents before stopping the plugins that serve them

A clean daemon shutdown stopped every plugin before it closed any agent.
Stopping a plugin withdraws its provider registrations, which pushes an
empty registration set through the agent provider registry, shuts the
plugin provider client down, and closes each still-open session with
`connectionClosed()`. That is reported as `session.runtime_failed` and
translated to `turn_failed`, so an agent whose last turn had completed
successfully was persisted with `lastError: "Provider connection closed"`
and came back after a restart carrying a failure it never had.

Stop tracking plugin provider registrations first so plugin teardown can
no longer withdraw a provider from under an open agent, and stop the
plugins themselves after `closeAllAgents()` and the provider runtime
shutdown have already made every session terminal. Closing the sessions
while their plugin is still running also means `session.close` now
reaches the provider.

Fixes #4877

* Bound each agent close so a silent provider cannot stall shutdown

Closing an agent asks its provider to close the session and waits for the
answer, and that request is only rejected when the provider runtime shuts
down. With agents now closing before the runtime does, a provider that
never answers `session.close` would leave `closeAllAgents()` waiting with
nothing left to unblock it.

Give each close a deadline. Whatever outlives it is abandoned and logged,
and the provider runtime shutdown that runs next rejects the request that
was still pending.

Also type the test fixture's `send` input as `ProviderInput`.

* Move the shutdown tests into the plugin lifecycle suite

Both tests exercise plugin lifecycle against a real daemon, which
lifecycle.e2e.test.ts already covers, so they go there rather than into a
suite of their own.

Drop the wall-clock assertion on how long stop() took. What the test
establishes is that stop() returns at all, and the suite timeout is what
enforces that; measuring elapsed time only added a second bound that a
loaded machine could trip.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 11:47:59 +02:00
paseo-bot[bot] 94ab368e4e Give daemon-spawned ACP terminals the agent's identity (#5248)
* fix(acp): give daemon-spawned terminals the agent's launch environment

A terminal the daemon opens for an ACP agent is a sibling of the agent
process, not a child, so it inherits nothing from it. createTerminal built
its environment from the requested terminal env alone, while the transport
spawn a hundred lines down already overlays the session's launchEnv. The
terminal therefore ran without PASEO_AGENT_ID or PASEO_AGENT_CWD, so
`paseo heartbeat create` refused to run and `paseo run` lost the caller,
creating child agents with no parent. When the daemon itself was started
from inside another Paseo agent, the terminal saw that agent's id instead.

Overlay launchEnv beneath the requested terminal env in both the argv and
the single-string shell branch, matching the transport spawn's precedence.

Fixes #5016

* test(acp): take createSession options as an object

The helper had grown a second optional positional parameter, making
callers read createSession(undefined, launchEnv).

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 09:44:02 +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
181 changed files with 9610 additions and 1269 deletions
+31
View File
@@ -180,6 +180,37 @@ jobs:
runs-on: windows-latest
steps: *server_test_steps
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
# runner that exercises it. macOS — the platform the reported watcher bug was on —
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime.
server-tests-macos:
name: server-tests (macos-14, file observation)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server dependencies
run: npm run build:server-deps
- name: Run file observation tests
run: |
npm run test:unit --workspace=@getpaseo/server -- \
src/server/file-observer \
src/server/workspace-git-service.observation.test.ts \
src/server/workspace-git-service.observation.integration.test.ts \
--bail=1
desktop-tests-ubuntu:
name: desktop-tests (ubuntu-latest)
needs: changes
+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
+72
View File
@@ -1,5 +1,77 @@
# Changelog
## 0.9.2 - 2026-09-24
### Added
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
### Improved
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
### Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
## 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) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
<!-- 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)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
<!-- 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). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
<!-- 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) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
<!-- 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 编写
+1 -1
View File
@@ -714,7 +714,7 @@ Each entry in the `models` array:
### Claude settings.json model discovery
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
+3 -3
View File
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
+5 -2
View File
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
## Session grants and agent operations
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
workspace through `workspace.title.set.request` without `workspace.manage`.
Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.
+1 -1
View File
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
+14 -5
View File
@@ -8,7 +8,8 @@ This guide walks through adding a new agent provider end-to-end. There are two i
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
top-level provider options.
This Paseo version accepts these keys:
@@ -19,10 +20,18 @@ This Paseo version accepts these keys:
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
`settings`. `providerOptions.extraArgs` passes the SDK's documented
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
@@ -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
@@ -0,0 +1,12 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,12 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,12 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,12 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
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.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.0",
"version": "0.9.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36196,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.0",
"version": "0.9.2",
"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.2",
"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.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/server": "0.9.2",
"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.2",
"dependencies": {
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37528,7 +37528,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.0",
"version": "0.9.2",
"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.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38668,7 +38668,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.0",
"version": "0.9.2",
"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.2",
"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.2",
"@getpaseo/protocol": "0.9.2",
"@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.2",
"@getpaseo/protocol": "0.9.2",
"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.2",
"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.2",
"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.2",
"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.2",
"@getpaseo/highlight": "0.9.2",
"@getpaseo/plugin": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@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.2",
"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.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,44 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
// Where Settings → Keyboard shortcuts persists a rebound shortcut.
const OVERRIDE_STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
const COMMAND_CENTER_BINDING = "command-center-toggle-ctrl-k-non-mac";
async function rebindCommandCenter(page: Page, combo: string): Promise<void> {
await page.addInitScript(
([key, bindingId, value]) => {
window.localStorage.setItem(key, JSON.stringify({ [bindingId]: value }));
},
[OVERRIDE_STORAGE_KEY, COMMAND_CENTER_BINDING, combo] as const,
);
await gotoAppShell(page);
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
}
async function expectCommandCenterOpen(page: Page): Promise<void> {
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 10_000 });
}
test.describe("Multi-step shortcut", () => {
test("completes a chord whose second step is a bare key", async ({ page }) => {
await rebindCommandCenter(page, "Ctrl+K J");
await page.keyboard.press("Control+k");
await page.keyboard.press("j");
await expectCommandCenterOpen(page);
});
// Releasing Ctrl between the steps makes the browser emit a bare `Control`
// keydown before `Ctrl+J`, which used to reset the chord to its first step.
test("completes a chord whose second step carries a modifier", async ({ page }) => {
await rebindCommandCenter(page, "Ctrl+K Ctrl+J");
await page.keyboard.press("Control+k");
await page.keyboard.press("Control+j");
await expectCommandCenterOpen(page);
});
});
@@ -0,0 +1,65 @@
import { expect, test, type Page } from "../support/fixtures";
// "Toggle command center" recorded as a two-key sequence. Both platform
// bindings are overridden so the spec does not depend on the host OS.
const COMMAND_CENTER_SEQUENCE = {
"command-center-toggle-cmd-k-mac": "Cmd+K J",
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K J",
};
const SEQUENCE_PREFIX = process.platform === "darwin" ? "Meta+K" : "Control+K";
const SEQUENCE_FINAL_KEY = "j";
/** Record the sequence in Settings → Keyboard shortcuts, through its storage. */
async function recordCommandCenterSequence(page: Page): Promise<void> {
await page.addInitScript((overrides) => {
localStorage.setItem("@paseo:keyboard-shortcut-overrides", JSON.stringify(overrides));
}, COMMAND_CENTER_SEQUENCE);
}
/**
* Resize the window and let the app render again, so the resize lands between
* the two keys of the sequence rather than after them.
*/
async function resizeWindowAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 800 });
await page.waitForFunction((expected) => window.innerWidth === expected, width);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
async function expectCommandCenterOpen(page: Page): Promise<void> {
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 30_000 });
}
test.describe("Multi-key shortcut sequences", () => {
test("a recorded sequence opens the command center", async ({ page, withWorkspace }) => {
await recordCommandCenterSequence(page);
const workspace = await withWorkspace({ prefix: "shortcut-sequence-" });
await workspace.navigateTo();
await page.keyboard.press(SEQUENCE_PREFIX);
await page.keyboard.press(SEQUENCE_FINAL_KEY);
await expectCommandCenterOpen(page);
});
test("a sequence completes when the window resizes between its keys", async ({
page,
withWorkspace,
}) => {
await recordCommandCenterSequence(page);
const workspace = await withWorkspace({ prefix: "shortcut-sequence-resize-" });
await workspace.navigateTo();
await page.keyboard.press(SEQUENCE_PREFIX);
await resizeWindowAndSettle(page, 1100);
await page.keyboard.press(SEQUENCE_FINAL_KEY);
await expectCommandCenterOpen(page);
});
});
@@ -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.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -54,6 +54,54 @@ describe("question form card core", () => {
});
});
test("keeps checked options and appends the other answer for multi-select", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Which fruits do you like?",
header: "Fruits",
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
multiSelect: true,
allowOther: true,
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, { 0: " durian " })).toEqual({
Fruits: "Apple, Cherry, durian",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, {})).toEqual({
Fruits: "Apple, Cherry",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set() }, { 0: "durian" })).toEqual({
Fruits: "durian",
});
expect(buildQuestionFormAnswers(questions, {}, { 0: "durian" })).toEqual({ Fruits: "durian" });
});
test("replaces the selected option with the other answer for single-select", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Which provider?",
header: "Provider",
options: [{ label: "Claude Code" }, { label: "Codex" }],
multiSelect: false,
allowOther: true,
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, { 0: "OpenCode" })).toEqual({
Provider: "OpenCode",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
Provider: "Codex",
});
});
test("shows text input for explicit other questions", () => {
const questions = parseQuestionFormQuestions({
questions: [
@@ -111,10 +111,13 @@ export function buildQuestionFormAnswers(
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) {
answers[q.header] = otherText;
// Multi-select keeps the checked options and appends the custom answer, the way
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
continue;
}
if (q.allowEmpty && q.options.length === 0) {
@@ -123,8 +126,7 @@ export function buildQuestionFormAnswers(
}
}
if (selected && selected.size > 0) {
const labels = Array.from(selected).map((idx) => q.options[idx].label);
if (labels.length > 0) {
answers[q.header] = labels.join(", ");
}
}
@@ -0,0 +1,149 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import type { PendingPermission } from "@/types/shared";
import { QuestionFormCard } from "./question-form-card";
// Load translations so controls expose their real accessible names.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
* gap between that input and React state: the input owns its text and never replays state, so a
* card that drops the Other text from state alone keeps showing it while submit ignores it.
*/
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
function buildPermission(question: Record<string, unknown>): PendingPermission {
return {
key: "perm-1",
agentId: "agent-1",
request: {
id: "perm-1",
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: { questions: [question] },
},
};
}
function mountCard(question: Record<string, unknown>) {
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() =>
root.render(
<QuestionFormCard
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
/>,
),
);
mounted.push({ root, container });
const view = within(container);
const optionRole = question.multiSelect ? "checkbox" : "radio";
const otherInput = () =>
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
const type = (text: string) => {
const input = otherInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
act(() => {
valueSetter.call(input, text);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
});
};
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
const submittedAnswers = (): Record<string, string> => {
const response = onRespond.mock.calls[0]?.[0];
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
return (response.updatedInput as { answers: Record<string, string> }).answers;
};
return { check, type, otherInput, submit, submittedAnswers };
}
const multiSelectQuestion = {
question: "Which fruits do you like?",
header: "Fruits",
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
multiSelect: true,
allowOther: true,
};
const singleSelectQuestion = {
question: "Which provider?",
header: "Provider",
options: [{ label: "Claude Code" }, { label: "Codex" }],
multiSelect: false,
allowOther: true,
};
describe("QuestionFormCard other answers", () => {
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
const card = mountCard(multiSelectQuestion);
card.check("Apple");
card.check("Cherry");
card.type("durian");
card.submit();
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
});
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
const card = mountCard(multiSelectQuestion);
card.type("durian");
card.check("Apple");
card.check("Banana");
expect(card.otherInput().value).toBe("durian");
card.submit();
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
});
it("replaces the selected option with the typed other answer (single-select)", () => {
const card = mountCard(singleSelectQuestion);
card.check("Codex");
card.type("OpenCode");
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
});
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
const card = mountCard(singleSelectQuestion);
card.type("OpenCode");
card.check("Codex");
expect(card.otherInput().value).toBe("");
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
});
});
@@ -1,5 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -9,6 +9,7 @@ import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
@@ -258,6 +259,7 @@ function QuestionNav({
interface QuestionOtherInputProps {
qIndex: number;
inputRef: RefObject<EditingTextInputHandle | null>;
accessibilityLabel: string;
value: string;
placeholder: string;
@@ -268,6 +270,7 @@ interface QuestionOtherInputProps {
function QuestionOtherInput({
qIndex,
inputRef,
accessibilityLabel,
value,
placeholder,
@@ -303,6 +306,7 @@ function QuestionOtherInput({
);
return (
<TextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
accessibilityLabel={accessibilityLabel}
@@ -328,6 +332,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const otherInputRef = useRef<EditingTextInputHandle | null>(null);
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
@@ -349,29 +354,40 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
setSelections((prev) => ({ ...prev, [qIndex]: next }));
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
// Single-select: an option and a custom answer replace each other, as in Claude Code.
// Multi-select keeps both. The editing surface owns its text and never replays state
// (docs/forms.md), so clearing state alone would leave stale text on screen that
// submit ignores; clear the surface explicitly.
if (!multiSelect && otherTexts[qIndex]) {
setOtherTexts((prev) => {
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
otherInputRef.current?.replaceText("");
}
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, questions, selections],
[activeQuestionIndex, otherTexts, questions, selections],
);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
if (text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
});
}
}, []);
const setOtherText = useCallback(
(qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
const multiSelect = questions?.[qIndex]?.multiSelect ?? false;
if (!multiSelect && text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
});
}
},
[questions],
);
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
const resolvedActiveQuestionIndex = questions
@@ -554,6 +570,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
{showTextInput ? (
<QuestionOtherInput
qIndex={resolvedActiveQuestionIndex}
inputRef={otherInputRef}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder({
@@ -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;
},
};
+280 -289
View File
@@ -27,6 +27,7 @@ import {
} from "@/keyboard/route-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
@@ -75,7 +76,6 @@ export function useKeyboardShortcuts({
const openProjectPickerAction = useOpenAddProject();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
const badgeModifierKeyRef = useRef<string | null | undefined>(undefined);
const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => {
@@ -107,271 +107,285 @@ export function useKeyboardShortcuts({
publishBrowserShortcutPolicy();
}, [isDesktopApp, publishBrowserShortcutPolicy]);
// Only the modifier that actually performs the workspace-index jump on this
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
// to drive badge visibility, so we set the flag matching this runtime.
// Derived from the effective bindings: `null` when the user unassigned or
// rebound the jump shortcut, and no `event.key` ever equals null, so the
// badges simply never appear.
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
{ isMac, isDesktop: isDesktopApp },
bindings,
);
// The keyup listener matches the released key against the current modifier,
// so a modifier held while the jump binding changes could never be released
// and the badges would stay up until a blur. Clear them when it changes.
useEffect(() => {
resetModifiers();
}, [badgeModifierKey, resetModifiers]);
const setBadgeModifierDown = (down: boolean) => {
const state = useKeyboardShortcutsStore.getState();
if (isDesktopApp) {
state.setCmdOrCtrlDown(down);
} else {
state.setAltDown(down);
}
};
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
};
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
"toggle-agent-list": toggleAgentList,
"toggle-both-sidebars": toggleBothSidebars,
"cycle-theme": cycleTheme,
};
const performShortcutAction = (
action: ShortcutAction,
event: KeyboardEvent | null,
browserFocusRestoreElement: HTMLElement | null = null,
): boolean => {
switch (action.kind) {
case "none":
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "navigate-workspace":
keyboardWorkspaceSelectionRef.current = {
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
return true;
case "navigate-last-workspace":
if (navigateToLastWorkspace()) {
return true;
}
router.replace(buildOpenProjectRoute());
return true;
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
case "router-back":
router.back();
return true;
case "router-push":
router.push(action.route as Parameters<typeof router.push>[0]);
return true;
case "open-project-picker":
void openProjectPickerAction();
return true;
case "callback":
callbacksByName[action.name]?.();
return true;
case "command-center-toggle": {
if (action.nextOpen) {
if (event) {
captureCommandCenterFocusRestore(event);
} else {
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
}
}
useKeyboardShortcutsStore
.getState()
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
return true;
}
case "shortcuts-dialog-toggle":
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
return true;
}
};
const routeAndPerformShortcut = (input: {
action: string;
payload: KeyboardShortcutPayload;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}): boolean => {
const store = useKeyboardShortcutsStore.getState();
const shortcutAction = routeKeyboardShortcut(
{ action: input.action, payload: input.payload },
{
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace:
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
);
const handled = performShortcutAction(
shortcutAction,
input.domEvent,
input.browserFocusRestoreElement,
);
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
exitFocusMode();
}
return handled;
};
const resolveAndPerformShortcut = (input: {
event: KeyboardShortcutInput;
focusScope: KeyboardFocusScope;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}) => {
const store = useKeyboardShortcutsStore.getState();
const previousChordState = chordStateRef.current;
const result = resolveKeyboardShortcut({
event: input.event,
context: {
isMac,
isDesktop: isDesktopApp,
focusScope: input.focusScope,
commandCenterOpen: store.commandCenterOpen,
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
publishBrowserShortcutPolicy();
},
bindings,
});
chordStateRef.current = result.nextChordState;
if (
shouldPublishBrowserShortcutPolicy({
isBrowserInput: "browserId" in input.event,
previousChordState,
nextChordState: result.nextChordState,
})
) {
publishBrowserShortcutPolicy(result.nextChordState);
}
if (result.preventDefault && input.domEvent) {
input.domEvent.preventDefault();
input.domEvent.stopPropagation();
}
if (!result.match) {
return;
}
const handled = routeAndPerformShortcut({
action: result.match.action,
payload: result.match.payload,
domEvent: input.domEvent,
browserFocusRestoreElement: input.browserFocusRestoreElement,
});
if (!handled || !input.domEvent) {
return;
}
if (result.match.preventDefault) {
input.domEvent.preventDefault();
}
if (result.match.stopPropagation) {
input.domEvent.stopPropagation();
}
};
// The window listeners must outlive ordinary re-renders: removing them resets
// any in-progress chord. Stable events let them read the latest render instead
// of being re-registered whenever a route, callback, or selection changes.
const handleKeyDown = useStableEvent((event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
if (dispatchTopWebOverlayKeyDown(event)) {
return;
}
// During IME composition, Enter confirms the candidate selection and must
// not route through global shortcuts like message send.
if (isImeComposingKeyboardEvent(event)) {
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;
}
const key = event.key ?? "";
if (
key === "Escape" &&
pathname.startsWith("/settings") &&
!isMobile &&
hasActiveWebOverlay()
) {
return;
}
if (key === badgeModifierKey && !event.shiftKey) {
setBadgeModifierDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
resolveAndPerformShortcut({
event,
focusScope,
domEvent: event,
});
});
const handleKeyUp = useStableEvent((event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === badgeModifierKey) {
setBadgeModifierDown(false);
}
});
const handleBrowserShortcutInput = useStableEvent((payload: unknown) => {
const input = parseBrowserShortcutInput(payload);
if (!input) {
return;
}
resolveAndPerformShortcut({
event: input,
focusScope: "browser",
domEvent: null,
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
});
});
useEffect(() => {
if (!enabled) return;
if (!shortcutsAvailable) return;
// Only the modifier that actually performs the workspace-index jump on this
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
// to drive badge visibility, so we set the flag matching this runtime.
// Derived from the effective bindings: `null` when the user unassigned or
// rebound the jump shortcut, and no `event.key` ever equals null, so the
// badges simply never appear.
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
{ isMac, isDesktop: isDesktopApp },
bindings,
);
const setBadgeModifierDown = (down: boolean) => {
const state = useKeyboardShortcutsStore.getState();
if (isDesktopApp) {
state.setCmdOrCtrlDown(down);
} else {
state.setAltDown(down);
}
};
// The keyup listener matches the released key against the modifier derived
// when the effect last ran, so a modifier held while the jump binding
// changes can never be released -- the badges would stay up until a blur.
// Clear on change only: this effect also re-runs on every navigation, and
// clearing unconditionally would drop the badges mid Cmd+1, Cmd+2.
if (badgeModifierKeyRef.current !== badgeModifierKey) {
badgeModifierKeyRef.current = badgeModifierKey;
resetModifiers();
}
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
};
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
"toggle-agent-list": toggleAgentList,
"toggle-both-sidebars": toggleBothSidebars,
"cycle-theme": cycleTheme,
};
const performShortcutAction = (
action: ShortcutAction,
event: KeyboardEvent | null,
browserFocusRestoreElement: HTMLElement | null = null,
): boolean => {
switch (action.kind) {
case "none":
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "navigate-workspace":
keyboardWorkspaceSelectionRef.current = {
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
return true;
case "navigate-last-workspace":
if (navigateToLastWorkspace()) {
return true;
}
router.replace(buildOpenProjectRoute());
return true;
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
case "router-back":
router.back();
return true;
case "router-push":
router.push(action.route as Parameters<typeof router.push>[0]);
return true;
case "open-project-picker":
void openProjectPickerAction();
return true;
case "callback":
callbacksByName[action.name]?.();
return true;
case "command-center-toggle": {
if (action.nextOpen) {
if (event) {
captureCommandCenterFocusRestore(event);
} else {
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
}
}
useKeyboardShortcutsStore
.getState()
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
return true;
}
case "shortcuts-dialog-toggle":
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
return true;
}
};
const routeAndPerformShortcut = (input: {
action: string;
payload: KeyboardShortcutPayload;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}): boolean => {
const store = useKeyboardShortcutsStore.getState();
const shortcutAction = routeKeyboardShortcut(
{ action: input.action, payload: input.payload },
{
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace:
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
);
const handled = performShortcutAction(
shortcutAction,
input.domEvent,
input.browserFocusRestoreElement,
);
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
exitFocusMode();
}
return handled;
};
const resolveAndPerformShortcut = (input: {
event: KeyboardShortcutInput;
focusScope: KeyboardFocusScope;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}) => {
const store = useKeyboardShortcutsStore.getState();
const previousChordState = chordStateRef.current;
const result = resolveKeyboardShortcut({
event: input.event,
context: {
isMac,
isDesktop: isDesktopApp,
focusScope: input.focusScope,
commandCenterOpen: store.commandCenterOpen,
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
publishBrowserShortcutPolicy();
},
bindings,
});
chordStateRef.current = result.nextChordState;
if (
shouldPublishBrowserShortcutPolicy({
isBrowserInput: "browserId" in input.event,
previousChordState,
nextChordState: result.nextChordState,
})
) {
publishBrowserShortcutPolicy(result.nextChordState);
}
if (result.preventDefault && input.domEvent) {
input.domEvent.preventDefault();
input.domEvent.stopPropagation();
}
if (!result.match) {
return;
}
const handled = routeAndPerformShortcut({
action: result.match.action,
payload: result.match.payload,
domEvent: input.domEvent,
browserFocusRestoreElement: input.browserFocusRestoreElement,
});
if (!handled || !input.domEvent) {
return;
}
if (result.match.preventDefault) {
input.domEvent.preventDefault();
}
if (result.match.stopPropagation) {
input.domEvent.stopPropagation();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
if (dispatchTopWebOverlayKeyDown(event)) {
return;
}
// During IME composition, Enter confirms the candidate selection and must
// not route through global shortcuts like message send.
if (isImeComposingKeyboardEvent(event)) {
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;
}
const key = event.key ?? "";
if (
key === "Escape" &&
pathname.startsWith("/settings") &&
!isMobile &&
hasActiveWebOverlay()
) {
return;
}
if (key === badgeModifierKey && !event.shiftKey) {
setBadgeModifierDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
resolveAndPerformShortcut({
event,
focusScope,
domEvent: event,
});
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === badgeModifierKey) {
setBadgeModifierDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
@@ -382,18 +396,7 @@ export function useKeyboardShortcuts({
document.addEventListener("visibilitychange", handleBlurOrHide);
const browserShortcutSubscription = isElectronRuntime()
? getDesktopHost()?.events?.on?.("browser-shortcut-input", (payload) => {
const input = parseBrowserShortcutInput(payload);
if (!input) {
return;
}
resolveAndPerformShortcut({
event: input,
focusScope: "browser",
domEvent: null,
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
});
})
? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput)
: null;
return () => {
if (chordStateRef.current.timeoutId !== null) {
@@ -415,23 +418,11 @@ export function useKeyboardShortcuts({
}
};
}, [
bindings,
cycleTheme,
enabled,
exitFocusMode,
activeWorkspaceSelection,
isDesktopApp,
isMac,
isMobile,
isWorkspaceFocusModeEnabled,
keyboardActionDispatcher,
openProjectPickerAction,
pathname,
publishBrowserShortcutPolicy,
handleBrowserShortcutInput,
handleKeyDown,
handleKeyUp,
resetModifiers,
router,
shortcutsAvailable,
toggleAgentList,
toggleBothSidebars,
]);
}
@@ -550,6 +550,36 @@ describe("keyboard-shortcuts", () => {
expectNoShortcutResolution({ event, context });
});
// A rebound pane-focus shortcut has to fire wherever the user is typing.
// Its default combo carries `editable: false` so that Cmd+Shift+Arrow keeps
// selecting text (the two cases above), and that guard describes the default
// combo rather than the action, so it must not survive the rebind.
describe("a rebound pane-focus shortcut", () => {
const PANE_FOCUS_DOWN_BINDING = "workspace-pane-focus-down-cmd-shift-down";
// macOS emits U+2206 for Option+J; the stored combo comes from the code.
const altJ = { key: "\u2206", code: "KeyJ", altKey: true };
it.each(["message-input", "editable"] as const)("fires with %s focused", (focusScope) => {
const result = resolveShortcut({
event: altJ,
context: { isMac: true, focusScope },
bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }),
});
expect(result.match?.action).toBe("workspace.pane.focus.down");
});
it("still fires outside a text field", () => {
const result = resolveShortcut({
event: altJ,
context: { isMac: true, focusScope: "other" },
bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }),
});
expect(result.match?.action).toBe("workspace.pane.focus.down");
});
});
it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
@@ -593,6 +623,42 @@ describe("keyboard-shortcuts", () => {
});
});
it("completes a chord whose second step carries a modifier", () => {
const bindings = buildEffectiveBindings({
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K Ctrl+J",
});
// The browser emits a keydown for the bare Control key before every combo,
// so the stream for Ctrl+K, release, Ctrl+J is four keydowns, not two.
const firstModifier = resolveShortcut({
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
context: { isMac: false, isDesktop: true },
bindings,
});
const firstStep = resolveShortcut({
event: { key: "k", code: "KeyK", ctrlKey: true },
context: { isMac: false, isDesktop: true },
chordState: firstModifier.nextChordState,
bindings,
});
expect(firstStep.nextChordState.step).toBe(1);
const secondModifier = resolveShortcut({
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
context: { isMac: false, isDesktop: true },
chordState: firstStep.nextChordState,
bindings,
});
const secondStep = resolveShortcut({
event: { key: "j", code: "KeyJ", ctrlKey: true },
context: { isMac: false, isDesktop: true },
chordState: secondModifier.nextChordState,
bindings,
});
expect(secondStep.match?.action).toBe("command-center.toggle");
});
it("schedules a chord reset timeout for advancing candidates", () => {
vi.useFakeTimers();
@@ -7,6 +7,7 @@ import type {
} from "@/keyboard/actions";
import {
chordStringToShortcutKeys,
isModifierKeyCode,
type KeyCombo,
parseChordString,
} from "@/keyboard/shortcut-string";
@@ -1229,14 +1230,33 @@ export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShor
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
}
const when = withoutDefaultComboGuard(binding.when);
if (!binding.help?.defaultDisplayKeys) {
return { ...binding, combo: override, parsedChord };
return { ...binding, combo: override, parsedChord, when };
}
const { defaultDisplayKeys: _defaultDisplayKeys, ...help } = binding.help;
return { ...binding, combo: override, parsedChord, help };
return { ...binding, combo: override, parsedChord, when, help };
});
}
/**
* `editable: false` is a statement about a binding's *default* combo, not
* about its action: the pane-focus defaults carry it so that Cmd+Shift+Arrow
* keeps selecting text in a field instead of moving pane focus. An override
* replaces that combo, so the guard no longer describes anything and has to
* go, the same way `defaultDisplayKeys` does — otherwise the combo the user
* picked in Settings silently refuses to fire wherever they are typing.
*
* The other guards stay. Platform, command center, terminal and focus scope
* are properties of the action and of where it makes sense, and none of them
* change because the keys did.
*/
function withoutDefaultComboGuard(when: ShortcutWhen | undefined): ShortcutWhen | undefined {
if (when?.editable !== false) return when;
const { editable: _editable, ...rest } = when;
return rest;
}
// --- Matching engine ---
function parseDigit(event: KeyboardShortcutInput): number | null {
@@ -1517,6 +1537,13 @@ export function resolveKeyboardShortcut(input: {
preventDefault: boolean;
} {
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
// Pressing a modifier emits its own keydown before the combo that holds it,
// so a chord waiting on `Ctrl+J` sees a bare `Control` first. That keydown
// matches no combo, and resolving it would drop the chord back to its first
// step. It decides nothing: leave the chord where it is.
if (isModifierKeyCode(event.code)) {
return { match: null, nextChordState: chordState, preventDefault: false };
}
if (chordState.step === 0) {
return resolveInitialChordStep({ event, context, chordState, onChordReset, bindings });
}
+10 -1
View File
@@ -245,8 +245,17 @@ export function heldModifiersFromEvent(event: KeyboardEvent): string | null {
return parts.length > 0 ? parts.join("+") : null;
}
/**
* Whether a keydown carries a modifier key itself rather than a key pressed
* with one. The browser emits one of these before every combo that holds a
* modifier, and it is the prefix of a combo, never a combo of its own.
*/
export function isModifierKeyCode(code: string): boolean {
return MODIFIER_CODES.has(code);
}
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
if (isModifierKeyCode(event.code)) {
return null;
}
@@ -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;
}
@@ -32,8 +32,11 @@ class MemoryStorage implements ReplicaRowStore {
writes = 0;
cleanups = 0;
nextWriteFailure: Error | null = null;
persistentWriteFailure: Error | null = null;
readGate: Promise<void> | null = null;
onRead: (() => void) | null = null;
/** Throws once a read goes past this count, so a non-terminating read loop fails the test. */
readLimit: number | null = null;
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
return `${row.serverId}:${row.kind}:${row.id}`;
@@ -47,6 +50,11 @@ class MemoryStorage implements ReplicaRowStore {
ids?: readonly string[],
): Promise<ReplicaRow[]> {
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
if (this.readLimit !== null && this.reads.length > this.readLimit) {
throw new Error(
`replica cache read ${this.reads.length} passed the limit of ${this.readLimit}`,
);
}
this.onRead?.();
await this.readGate;
const acceptedKinds = new Set(kinds);
@@ -71,6 +79,7 @@ class MemoryStorage implements ReplicaRowStore {
async apply(changes: ReplicaRowChanges): Promise<void> {
this.writes += 1;
if (this.persistentWriteFailure) throw this.persistentWriteFailure;
if (this.nextWriteFailure) {
const error = this.nextWriteFailure;
this.nextWriteFailure = null;
@@ -432,6 +441,34 @@ describe("ReplicaCache", () => {
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
});
it("gives up a read instead of retrying while the store keeps rejecting writes", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
storage.persistentWriteFailure = new Error("QuotaExceededError");
storage.reads.length = 0;
storage.readLimit = 5;
deleteDirectory(cache, SERVER_ID);
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
expect(storage.reads.length).toBeLessThanOrEqual(1);
});
it("still reads a host whose rows are stored when another host's write is rejected", async () => {
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage, noLegacyCleanup);
cache.setHosts([SERVER_ID, "other-host"]);
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
storage.persistentWriteFailure = new Error("QuotaExceededError");
commitDirectory(cache, "other-host", directory());
expect((await cache.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.id).toBe("workspace-1");
});
it("discards a durable read when the host changes while it is in flight", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
@@ -983,7 +983,11 @@ export class ReplicaCache {
try {
await this.prepareStore();
while (this.activeServerIds.has(serverId)) {
await this.flush();
// A read may only answer from rows the store already holds, so it waits for this host's
// accepted commits to land. A store that rejects them will keep rejecting them: fail closed
// rather than re-attempt the write on every pass. A write rejected for another host leaves
// this host's stored rows readable.
if (!(await this.syncPending()) && this.hasPendingHostChanges(serverId)) return [];
const revision = this.hostRevisions.get(serverId) ?? 0;
const rows = await this.rowStore.read(serverId, kinds, ids);
if (this.canReadHostRevision(serverId, revision)) return rows;
@@ -1173,20 +1177,26 @@ export class ReplicaCache {
}
async flush(): Promise<void> {
await this.persist();
await this.syncPending();
}
/** Resolves to whether every pending change reached the store. */
private async syncPending(): Promise<boolean> {
const persisted = await this.persist();
await this.writeQueue.catch(() => undefined);
return persisted;
}
private async flushPending(): Promise<void> {
await this.persist();
}
private async persist(): Promise<void> {
private async persist(): Promise<boolean> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
if (!this.hasPendingChanges()) return;
if (!this.hasPendingChanges()) return true;
const write = this.writeQueue
.catch(() => undefined)
.then(async () => {
@@ -1206,11 +1216,16 @@ export class ReplicaCache {
} catch {
this.restorePendingChanges(pending);
if (this.hasPendingChanges()) this.schedulePersist();
return false;
}
return undefined;
return true;
});
this.writeQueue = write;
await write;
// The queue only sequences writes; every consumer decides for itself what a failure means.
this.writeQueue = write.then(
() => undefined,
() => undefined,
);
return write;
}
private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void {
@@ -377,8 +377,7 @@ export function KeyboardShortcutsSection() {
event.preventDefault();
event.stopPropagation();
const key = event.key ?? "";
if (key === "Backspace") {
if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) {
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
return;
}
@@ -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[];
}
+59 -1
View File
@@ -4,6 +4,10 @@ import type { AudioEngine } from "@/voice/audio-engine-types";
import { createVoiceRuntime, type VoiceSessionAdapter } from "@/voice/voice-runtime";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
const CUE_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
const THINKING_TONE_MIN_SILENCE_MS = 1500;
const SEGMENT_MIME_TYPE = "audio/pcm;rate=24000;bits=16";
function createAudioEngineMock(): AudioEngine {
return {
initialize: vi.fn().mockResolvedValue(undefined),
@@ -252,10 +256,11 @@ describe("voice runtime", () => {
runtime.onAssistantAudioFinished("server-1");
expect(runtime.getSnapshot().phase).toBe("waiting");
await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS);
expect(engine.play).toHaveBeenCalled();
});
it("starts the thinking tone when an agent turn begins before playback", async () => {
it("starts the thinking tone once the wait for a reply outlasts an inter-segment gap", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
@@ -264,6 +269,10 @@ describe("voice runtime", () => {
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
expect(runtime.getSnapshot().phase).toBe("waiting");
await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS - 1);
expect(engine.play).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(engine.play).toHaveBeenCalled();
});
@@ -284,6 +293,7 @@ describe("voice runtime", () => {
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
expect(runtime.getSnapshot().phase).toBe("waiting");
await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS);
expect(engine.play).toHaveBeenCalledTimes(1);
vi.mocked(engine.stop).mockClear();
vi.mocked(engine.clearQueue).mockClear();
@@ -303,6 +313,53 @@ describe("voice runtime", () => {
resolvePlay(0.1);
});
it("does not play the thinking tone in the gap between two segments of one reply", async () => {
const adapter = createSessionAdapter();
const engine = createAudioEngineMock();
const played: string[] = [];
vi.mocked(engine.play).mockImplementation(async (source: { type: string }) => {
played.push(source.type);
return 0.1;
});
const { runtime } = createRuntime({ engine });
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
runtime.handleAudioOutput(
"server-1",
createAudioPayload({
id: "seg-1-chunk-0",
groupId: "seg-1",
chunkIndex: 0,
isLastChunk: true,
}),
);
await vi.waitFor(() => {
expect(runtime.getSnapshot().phase).toBe("waiting");
});
await vi.advanceTimersByTimeAsync(400);
runtime.handleAudioOutput(
"server-1",
createAudioPayload({
id: "seg-2-chunk-0",
groupId: "seg-2",
chunkIndex: 0,
isLastChunk: true,
}),
);
await vi.waitFor(() => {
expect(played).toContain(SEGMENT_MIME_TYPE);
});
const replyStart = played.indexOf(SEGMENT_MIME_TYPE);
expect(replyStart).toBeGreaterThanOrEqual(0);
expect(played.slice(replyStart).filter((type) => type === CUE_MIME_TYPE)).toEqual([]);
});
it("returns to listening after assistant playback once the turn is complete", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
@@ -310,6 +367,7 @@ describe("voice runtime", () => {
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS);
runtime.onAssistantAudioStarted("server-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_completed");
runtime.onAssistantAudioFinished("server-1");
+8 -1
View File
@@ -11,6 +11,13 @@ import {
const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
const KEEP_AWAKE_TAG = "paseo:voice";
const THINKING_TONE_REPEAT_GAP_MS = 350;
/**
* A reply is spoken as one TTS segment per sentence, and the daemon starts the next segment
* only once the current one has finished playing, so a reply in progress is punctuated by short
* silences. The cue is for a wait the user cannot otherwise explain, so it starts only once the
* silence has outlasted those gaps.
*/
const THINKING_TONE_MIN_SILENCE_MS = 1500;
const DISPLAY_VOLUME_PUBLISH_INTERVAL_MS = 120;
const DISPLAY_VOLUME_CHANGE_EPSILON = 0.02;
const DISPLAY_VOLUME_ATTACK = 0.35;
@@ -492,7 +499,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
});
};
playNext();
cue.timeout = setTimeout(playNext, THINKING_TONE_MIN_SILENCE_MS);
}
const uploader: ContinuousVoiceUploader = {
@@ -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.2",
"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.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/server": "0.9.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -5,6 +5,7 @@ import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { hashDaemonPassword } from "@getpaseo/server/auth";
import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server/daemon-control";
import { expect, test } from "vitest";
import { connectToDaemon } from "../../utils/client.js";
@@ -196,6 +197,26 @@ test("removed flags and ambiguous targets fail before side effects; observation
}
}, 30_000);
test("status reports the server id of a password-protected daemon to a caller without the password", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const configPath = path.join(home, "config.json");
const config = JSON.parse(await readFile(configPath, "utf8"));
config.daemon.auth = { password: await hashDaemonPassword("secret") };
await writeFile(configPath, JSON.stringify(config));
await f.ok(["start", "--home", home, "--timeout", "30"]);
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
connectedDaemon: "auth_required",
serverId: authenticated.serverId,
});
} finally {
await f.close();
}
}, 60_000);
test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
@@ -591,6 +612,25 @@ test("malformed configuration cannot turn a readiness timeout into launch cancel
}
}, 60_000);
test("a background start that fails before the supervisor logs names the cause in a log that exists", async () => {
const f = await fixture();
const home = f.homes[0]!;
const configPath = path.join(home, "config.json");
try {
await mkdir(home, { recursive: true });
await writeFile(configPath, '{"version":1,');
const failed = await f.run(["daemon", "start", "--home", home, "--timeout", "30"]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain(`Logs: ${path.join(home, "daemon.log")}`);
expect(failed.stderr).toContain(`Invalid JSON in ${configPath}`);
expect(await readFile(path.join(home, "daemon.log"), "utf8")).toContain(
`Invalid JSON in ${configPath}`,
);
} finally {
await f.close();
}
}, 60_000);
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
const f = await fixture();
const home = f.homes[0]!;
@@ -124,6 +124,7 @@ function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemo
pid: instance?.pid ?? null,
startedAt: instance?.startedAt ?? null,
listen: instance?.listen ?? null,
serverId: instance?.serverId ?? undefined,
hostname: instance?.hostname ?? null,
configuredListen: config.listen,
localDaemon,
+17 -8
View File
@@ -16,7 +16,7 @@ export interface PermissionResponseItem {
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
idField: "requestId",
columns: [
{ header: "REQUEST ID", field: "requestId", width: 12 },
{ header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 },
{ header: "AGENT", field: "agentShortId", width: 10 },
{ header: "TOOL", field: "name", width: 20 },
{
@@ -32,6 +32,21 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
],
};
/** Transform a permission the agent answered to a response item */
export function toPermissionResponseItem(
agentId: string,
permission: AgentPermissionRequest,
result: "allowed" | "denied",
): PermissionResponseItem {
return {
requestId: permission.id,
agentId,
agentShortId: agentId.slice(0, 7),
name: permission.name,
result,
};
}
export type PermitAllowResult = ListResult<PermissionResponseItem>;
export interface PermitAllowOptions extends CommandOptions {
@@ -118,13 +133,7 @@ export async function runAllowCommand(
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
return {
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: "allowed",
};
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
+6 -8
View File
@@ -2,7 +2,11 @@ import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
import {
permitResponseSchema,
toPermissionResponseItem,
type PermissionResponseItem,
} from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>;
@@ -83,13 +87,7 @@ export async function runDenyCommand(
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
return {
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: "denied",
};
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
+19 -17
View File
@@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: "id",
columns: [
{ header: "AGENT", field: "agentShortId", width: 12 },
{ header: "REQ_ID", field: "id", width: 12 },
{ header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 },
{ header: "TOOL", field: "name", width: 20 },
{ header: "DESCRIPTION", field: "description", width: 50 },
],
@@ -30,7 +30,7 @@ function toListItem(
permission: AgentPermissionRequest,
): PermissionListItem {
return {
id: permission.id.slice(0, 8),
id: permission.id,
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
name: permission.name,
@@ -40,6 +40,22 @@ function toListItem(
export type PermitLsResult = ListResult<PermissionListItem>;
/** Collect the pending permissions of every agent */
export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult {
const items: PermissionListItem[] = [];
for (const agent of agents) {
for (const permission of agent.pendingPermissions ?? []) {
items.push(toListItem(agent, permission));
}
}
return {
type: "list",
data: items,
schema: permitLsSchema,
};
}
export interface PermitLsOptions extends CommandOptions {
host?: string;
}
@@ -55,21 +71,7 @@ export async function runLsCommand(
const agents = agentsPayload.entries.map((entry) => entry.agent);
await client.close();
// Collect all pending permissions from all agents
const items: PermissionListItem[] = [];
for (const agent of agents) {
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
for (const permission of agent.pendingPermissions) {
items.push(toListItem(agent, permission));
}
}
}
return {
type: "list",
data: items,
schema: permitLsSchema,
};
return listPendingPermissions(agents);
} catch (err) {
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,45 @@
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { permitResponseSchema, toPermissionResponseItem } from "./allow.js";
import { listPendingPermissions } from "./ls.js";
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
function permissionRequest(id: string): AgentPermissionRequest {
return { id, provider: "claude", name: "Write", kind: "tool" };
}
const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783");
const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb");
const agent = {
id: agentId,
pendingPermissions: [writeRequest, editRequest],
} as AgentSnapshotPayload;
describe("permit ls output", () => {
it("emits full request ids that permit allow accepts", () => {
const result = listPendingPermissions([agent]);
const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>;
expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]);
expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]);
});
});
describe("permit allow and deny output", () => {
it("emits the full id of the request that was answered", () => {
const result = {
type: "list" as const,
data: [toPermissionResponseItem(agentId, writeRequest, "allowed")],
schema: permitResponseSchema,
};
const answered = JSON.parse(render(result, { format: "json" })) as Array<{
requestId: string;
}>;
expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]);
});
});
+11 -4
View File
@@ -52,13 +52,20 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
return {
code,
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
details:
options.target.kind === "instance"
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
details: describeConnectionRemedy(code, options.target),
};
}
function describeConnectionRemedy(code: string, target: DaemonTarget): string {
if (code === "AUTH_REQUIRED")
return "The daemon requires a password. Set PASEO_PASSWORD and retry.";
if (code === "AUTH_FAILED")
return "The daemon rejected the password. Check PASEO_PASSWORD and retry.";
if (target.kind === "instance")
return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`;
return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.";
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
+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",
@@ -0,0 +1,96 @@
#!/usr/bin/env npx tsx
/**
* Regression: a reboot reassigns the supervisor's PID to an unrelated process. The lock
* left behind must not make the daemon look like it is running, and must never be treated
* as a handle on whatever now holds that PID.
*/
import assert from "node:assert";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir, uptime } from "node:os";
import { join } from "node:path";
import { runLocalPaseo } from "./helpers/local-cli.ts";
console.log("=== Daemon lock left behind by a reboot ===\n");
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stale-boot-lock-"));
const env = {
PASEO_HOME: paseoHome,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
};
// Stands in for the process that inherits the supervisor's PID after a reboot. It records a
// signal rather than dying of it, so a late delivery cannot slip past the assertion.
const signalMarker = join(paseoHome, "bystander-signalled");
const bystander = spawn(
process.execPath,
[
"-e",
`process.on("SIGTERM", () => require("node:fs").writeFileSync(${JSON.stringify(signalMarker)}, "SIGTERM"));` +
`setTimeout(() => {}, 120_000);`,
],
{ stdio: "ignore" },
);
let bystanderExited = false;
bystander.once("exit", () => {
bystanderExited = true;
});
assert(bystander.pid, "bystander process should have a pid");
try {
const beforeThisBoot = new Date(Date.now() - uptime() * 1000 - 60 * 60_000);
await writeFile(
join(paseoHome, "paseo.pid"),
JSON.stringify({
pid: bystander.pid,
startedAt: beforeThisBoot.toISOString(),
hostname: "before-reboot",
uid: process.getuid?.() ?? 0,
listen: "127.0.0.1:6767",
desktopManaged: true,
heartbeat: true,
}),
);
const statusResult = await runLocalPaseo(
["daemon", "status", "--home", paseoHome, "--json"],
env,
);
assert.strictEqual(
statusResult.exitCode,
0,
`daemon status should succeed:\nstdout:\n${statusResult.stdout}\nstderr:\n${statusResult.stderr}`,
);
const status = JSON.parse(statusResult.stdout) as { localDaemon?: string; pid?: number | null };
assert.strictEqual(
status.localDaemon,
"stopped",
`daemon left behind by a reboot should read as stopped, got: ${statusResult.stdout}`,
);
assert.strictEqual(status.pid, null, "status should not report the reused pid as the daemon");
console.log("✓ daemon status reports stopped\n");
const stopResult = await runLocalPaseo(["daemon", "stop", "--home", paseoHome], env);
assert.strictEqual(
stopResult.exitCode,
0,
`daemon stop should succeed:\nstdout:\n${stopResult.stdout}\nstderr:\n${stopResult.stderr}`,
);
assert.strictEqual(
existsSync(signalMarker),
false,
"daemon stop must not signal the process that inherited the pid",
);
assert.strictEqual(bystanderExited, false, "the process that inherited the pid should survive");
console.log("✓ daemon stop leaves the process holding that pid alone\n");
} finally {
bystander.kill("SIGKILL");
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Daemon lock left behind by a reboot passed ===");
@@ -0,0 +1,51 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import { startTestDaemon } from "./helpers/test-daemon.ts";
console.log("=== Daemon Auth Command Errors ===\n");
const daemon = await startTestDaemon({
env: { PASEO_PASSWORD: "shared-secret" },
});
async function lsError(password: string) {
const result = await runLocalPaseo(["ls", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: password,
});
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
return JSON.parse(result.stderr).error as {
code: string;
message: string;
details: string;
};
}
try {
{
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
const error = await lsError("");
assert.strictEqual(error.code, "AUTH_REQUIRED");
assert.match(error.message, /Password required/);
assert.match(error.details, /PASEO_PASSWORD/);
assert.doesNotMatch(error.details, /daemon start/);
console.log("✓ missing password points at PASEO_PASSWORD\n");
}
{
console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start");
const error = await lsError("wrong-secret");
assert.strictEqual(error.code, "AUTH_FAILED");
assert.match(error.message, /Incorrect password/);
assert.match(error.details, /PASEO_PASSWORD/);
assert.doesNotMatch(error.details, /daemon start/);
console.log("✓ wrong password points at PASEO_PASSWORD\n");
}
} finally {
await daemon.stop();
}
console.log("=== Daemon Auth Command Errors Tests Passed ===");
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.9.0",
"version": "0.9.2",
"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.2",
"@getpaseo/relay": "0.9.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -14,10 +14,13 @@ const SHORTCUTS_ROW = "show-shortcuts";
* daemon is left unmanaged so the app talks to the E2E daemon instead of trying
* to start one of its own.
*/
async function installDesktopBridge(page: Page): Promise<void> {
await page.addInitScript(() => {
async function installDesktopBridge(
page: Page,
platform: "darwin" | "win32" = "darwin",
): Promise<void> {
await page.addInitScript((desktopPlatform) => {
window.paseoDesktop = {
platform: "darwin",
platform: desktopPlatform,
events: { on: () => () => {} },
invoke: async (command: string) => {
if (command === "get_desktop_settings") {
@@ -29,11 +32,11 @@ async function installDesktopBridge(page: Page): Promise<void> {
return null;
},
};
});
}, platform);
}
async function openShortcutsSettings(page: Page) {
await installDesktopBridge(page);
async function openShortcutsSettings(page: Page, platform: "darwin" | "win32" = "darwin") {
await installDesktopBridge(page, platform);
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "shortcuts");
@@ -67,6 +70,38 @@ async function closeRowMenu(page: Page) {
await expect(page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`)).toHaveCount(0);
}
async function clearShortcut(page: Page, label: string, action: string) {
await page.getByRole("button", { name: `Actions for ${label}` }).click();
await page.getByTestId(`shortcut-clear-${action}`).click();
}
async function captureShortcut(page: Page, label: string, action: string, keys: string) {
await page.getByRole("button", { name: `Actions for ${label}` }).click();
await page.getByTestId(`shortcut-bind-${action}`).click();
await page.keyboard.press(keys);
}
async function expectInterruptShortcut(page: Page, chord: string) {
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
await expect(row.getByText(chord, { exact: true })).toBeVisible();
}
async function finishInterruptCapture(page: Page, action: "Done" | "Cancel") {
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
await row.getByRole("button", { name: action }).click();
}
async function eraseLastCapturedCombo(page: Page, firstCombo: string, secondCombo: string) {
await captureShortcut(page, "Interrupt agent", "agent-interrupt", "Alt+K");
await page.keyboard.press("Alt+J");
await expectInterruptShortcut(page, firstCombo);
await expectInterruptShortcut(page, secondCombo);
await page.keyboard.press("Backspace");
await expectInterruptShortcut(page, firstCombo);
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
await expect(row.getByText(secondCombo, { exact: true })).toHaveCount(0);
}
test("unassigning a shortcut leaves it inert until it is reset", async ({ page }) => {
await openShortcutsSettings(page);
@@ -146,3 +181,35 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
});
for (const { platform, keys, label, firstCombo, secondCombo } of [
{
platform: "darwin",
keys: "Meta+Shift+Backspace",
label: "⇧⌘⌫",
firstCombo: "⌥K",
secondCombo: "⌥J",
},
{
platform: "win32",
keys: "Control+Shift+Backspace",
label: "Ctrl+Shift+⌫",
firstCombo: "Alt+K",
secondCombo: "Alt+J",
},
] as const) {
test(`binds modified Backspace and erases a combo with bare Backspace on ${platform}`, async ({
page,
}) => {
await openShortcutsSettings(page, platform);
await clearShortcut(page, "Archive workspace", "archive-workspace");
await captureShortcut(page, "Interrupt agent", "agent-interrupt", keys);
await expectInterruptShortcut(page, label);
await finishInterruptCapture(page, "Done");
await expectInterruptShortcut(page, label);
await eraseLastCapturedCombo(page, firstCombo, secondCombo);
await finishInterruptCapture(page, "Cancel");
await expectInterruptShortcut(page, label);
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.9.0",
"version": "0.9.2",
"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.2",
"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.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.9.0",
"version": "0.9.2",
"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.2",
"@getpaseo/protocol": "0.9.2",
"@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.2",
"@getpaseo/protocol": "0.9.2",
"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.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.9.0",
"version": "0.9.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.9.0",
"version": "0.9.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -82,6 +82,7 @@
"measure:agent-tools": "tsx scripts/measure-agent-tools-context.ts",
"measure:git-observation": "tsx scripts/measure-git-observation.ts",
"measure:file-observer": "tsx scripts/measure-file-observer.ts",
"analyze:observer-metrics": "tsx scripts/analyze-observer-metrics.ts",
"repro:file-observer-teardown": "tsx scripts/reproduce-file-observer-teardown.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
"speech:download": "tsx scripts/download-speech-models.ts",
@@ -106,11 +107,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.2",
"@getpaseo/highlight": "0.9.2",
"@getpaseo/plugin": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -0,0 +1,137 @@
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
interface Sample {
uptimeSeconds: number;
trackedFiles: number;
renames: number;
classifications: number;
pendingClassifications: number;
pendingWork: number;
maxReconciliationMs: number;
eventLoopMaxMs: number;
rssBytes: number;
failures: number;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
// Pino lines are prefixed with a plain-text timestamp; the JSON payload starts
// at the first `{`. Lines that aren't JSON (or aren't ws_runtime_metrics) are skipped.
function parseLogLine(line: string): Record<string, unknown> | undefined {
const start = line.indexOf("{");
if (start < 0) return undefined;
try {
return asRecord(JSON.parse(line.slice(start)));
} catch {
return undefined;
}
}
function extractObserverRecord(root: Record<string, unknown>): Record<string, unknown> | undefined {
const workspaceService = asRecord(asRecord(root.git)?.workspaceService);
return asRecord(workspaceService?.fileObserver);
}
function buildSample(root: Record<string, unknown>, observer: Record<string, unknown>): Sample {
const eventLoopDelay = asRecord(root.eventLoopDelay);
const memory = asRecord(root.memory);
return {
uptimeSeconds: asNumber(root.uptimeSeconds) ?? 0,
trackedFiles: asNumber(observer.nativeTrackedFileCount) ?? 0,
renames: asNumber(observer.nativeRenameEventCount) ?? 0,
classifications: asNumber(observer.nativeClassificationCount) ?? 0,
pendingClassifications: asNumber(observer.pendingClassificationCount) ?? 0,
pendingWork: asNumber(observer.pendingReconciliationWorkCount) ?? 0,
maxReconciliationMs: asNumber(observer.maxReconciliationDurationMs) ?? 0,
eventLoopMaxMs: asNumber(eventLoopDelay?.maxMs) ?? 0,
rssBytes: asNumber(memory?.rss) ?? 0,
failures:
(asNumber(observer.observerFailureCount) ?? 0) +
(asNumber(observer.reconciliationFailureCount) ?? 0) +
(asNumber(observer.directoryLimitFailureCount) ?? 0),
};
}
async function readSamples(path: string): Promise<Sample[]> {
const samples: Sample[] = [];
const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
for await (const line of lines) {
const root = parseLogLine(line);
if (!root) continue;
const observer = extractObserverRecord(root);
if (!observer) continue;
samples.push(buildSample(root, observer));
}
return samples;
}
// A daemon restart resets uptime, so a decrease starts a new run.
function splitRuns(samples: Sample[]): Sample[][] {
const runs: Sample[][] = [];
let current: Sample[] = [];
let previous = Number.NEGATIVE_INFINITY;
for (const sample of samples) {
if (sample.uptimeSeconds < previous && current.length > 0) {
runs.push(current);
current = [];
}
current.push(sample);
previous = sample.uptimeSeconds;
}
if (current.length > 0) runs.push(current);
return runs;
}
function peak(run: Sample[], select: (sample: Sample) => number): number {
return run.reduce((highest, sample) => Math.max(highest, select(sample)), 0);
}
const logPath = process.argv[2];
if (!logPath) {
console.error("usage: analyze-observer-metrics <path-to-daemon.log>");
process.exit(1);
}
const runs = splitRuns(await readSamples(logPath));
const header = [
"run".padStart(3),
"samples".padStart(7),
"hours".padStart(6),
"peakTracked".padStart(11),
"renames".padStart(10),
"classify".padStart(10),
"peakPendClass".padStart(13),
"peakPendWork".padStart(12),
"maxReconS".padStart(9),
"peakLoopS".padStart(9),
"peakRssMB".padStart(9),
"fails".padStart(5),
].join(" ");
console.log(header);
runs.forEach((run, index) => {
console.log(
[
String(index + 1).padStart(3),
String(run.length).padStart(7),
(run[run.length - 1].uptimeSeconds / 3600).toFixed(1).padStart(6),
String(peak(run, (s) => s.trackedFiles)).padStart(11),
String(peak(run, (s) => s.renames)).padStart(10),
String(peak(run, (s) => s.classifications)).padStart(10),
String(peak(run, (s) => s.pendingClassifications)).padStart(13),
String(peak(run, (s) => s.pendingWork)).padStart(12),
(peak(run, (s) => s.maxReconciliationMs) / 1000).toFixed(1).padStart(9),
(peak(run, (s) => s.eventLoopMaxMs) / 1000).toFixed(1).padStart(9),
(peak(run, (s) => s.rssBytes) / 1048576).toFixed(0).padStart(9),
String(peak(run, (s) => s.failures)).padStart(5),
].join(" "),
);
});
@@ -1,10 +1,14 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import { promisify } from "node:util";
import { createFileObserver, type FileChange } from "../src/server/file-observer/index.js";
import {
createFileObserver,
type FileChange,
type FileObserverSubscription,
} from "../src/server/file-observer/index.js";
const DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_DIRS", 500);
const ROOT_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_ROOTS", 1);
@@ -17,8 +21,58 @@ const SUSTAINED_EDIT_INTERVAL_MS = readPositiveInteger(
500,
);
const REPETITIONS = readPositiveInteger("PASEO_WATCH_BENCH_REPETITIONS", 1);
const MODE = resolveMode();
const LATE_IGNORED_DIRS = readPositiveInteger("PASEO_WATCH_BENCH_LATE_IGNORED_DIRS", 2_000);
const LATE_IGNORED_FILES = readPositiveInteger("PASEO_WATCH_BENCH_LATE_IGNORED_FILES", 20_000);
// directory-churn mode exercises removeSubtree/reconcileSubtree/paths.collapse
// in packages/server/src/server/file-observer/internal/native-recursive.ts,
// which the healthy and late-ignored fixtures never touch: they only
// mkdir/writeFile, and every delete happens after unsubscribe.
const CHURN_DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_CHURN_DIRECTORIES", 20_000);
const CHURN_FILES_PER_DIRECTORY = readPositiveInteger(
"PASEO_WATCH_BENCH_CHURN_FILES_PER_DIRECTORY",
3,
);
const CHURN_ROUNDS = readPositiveInteger("PASEO_WATCH_BENCH_CHURN_ROUNDS", 3);
const CHURN_DIRECTORIES_PER_ROUND = readPositiveInteger(
"PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND",
500,
);
// Sustained rename churn (the production symptom this mode approximates) can
// leave a single reconciliation running for minutes once the tracked set is
// large; give it room to finish instead of calling that a timeout failure.
const CHURN_SETTLE_TIMEOUT_MS = readPositiveInteger(
"PASEO_WATCH_BENCH_CHURN_SETTLE_TIMEOUT_MS",
300_000,
);
// Sequential `await`-per-entry construction lets the observer's pending
// fs.stat() classifications drain between filesystem calls, so the storm this
// mode exists to reproduce never piles up: the sequential version could never
// push pendingClassificationCount past 32, nowhere near the 2,080 bound
// (32 concurrent + 2,048 queued) a later fix is measured against. Firing
// batches of concurrent mkdir/writeFile calls overwhelms the libuv threadpool
// that both the harness's own fs calls and the observer's classify() stat()
// calls share, which is what produces a real backlog. The batch size is
// derived from this process's actual open-file limit (see
// resolveLateIgnoredBatchSize below) rather than hardcoded, so a replay on a
// machine with a much lower `ulimit -n` (common on Linux/CI, historically on
// macOS too) degrades to a smaller burst instead of throwing EMFILE mid-run.
const LATE_IGNORED_BATCH_SIZE = resolveLateIgnoredBatchSize();
// Reuses the same descriptor-aware sizing as LATE_IGNORED_BATCH_SIZE above:
// directory-churn mode needs concurrent, not sequential, filesystem calls to
// outrun the audit's 500ms quiet window and force scopes to pile up.
const CHURN_BATCH_SIZE = resolveLateIgnoredBatchSize();
const execFileAsync = promisify(execFile);
type BenchMode = "healthy" | "late-ignored" | "directory-churn";
function resolveMode(): BenchMode {
const raw = process.env.PASEO_WATCH_BENCH_MODE;
if (raw === "late-ignored") return "late-ignored";
if (raw === "directory-churn") return "directory-churn";
return "healthy";
}
interface Measurement {
backend: string;
run: number;
@@ -40,6 +94,8 @@ interface Measurement {
sustainedNativeClassificationCount: number | null;
sustainedNativeShallowScanCount: number | null;
sustainedMissedPaths: number;
lateIgnoredTrackedFileCount: number | null;
peakPendingClassificationCount: number;
teardownMs: number;
startupEvents: number;
deliveredEvents: number;
@@ -53,6 +109,16 @@ interface Measurement {
kernelWatchCount: number | null;
scopedReconciliationCount: number | null;
fullReconciliationCount: number | null;
// Absolute values (not deltas) read straight off diagnostics: the observer
// instance is fresh per run, so "max"/"last" already scope to this run.
maxReconciliationDurationMs: number;
lastReconciliationDurationMs: number;
// directory-churn mode only; null for healthy/late-ignored.
nativeRenameEventCount: number | null;
churnDirectoryCount: number | null;
churnFileCount: number | null;
churnRounds: number | null;
churnDirectoriesChurned: number | null;
}
interface FixtureRoot {
@@ -65,7 +131,7 @@ async function main(): Promise<void> {
const isChild = process.argv.includes("--child");
if (isChild) {
process.stdout.write(`${JSON.stringify(await measure(1))}\n`);
process.stdout.write(`${JSON.stringify(await runMeasurement(1))}\n`);
return;
}
@@ -80,6 +146,10 @@ async function main(): Promise<void> {
if (!evaluation.passed) process.exitCode = 1;
}
function runMeasurement(run: number): Promise<Measurement> {
return MODE === "directory-churn" ? measureDirectoryChurn(run) : measure(run);
}
async function measureInChild(run: number): Promise<Measurement> {
const { stdout } = await execFileAsync(
process.execPath,
@@ -98,7 +168,7 @@ async function measure(run: number): Promise<Measurement> {
const ignoredRoot = join(root, "ignored");
const trackedRoot = join(root, "tracked");
await Promise.all([
mkdir(ignoredRoot, { recursive: true }),
MODE === "healthy" ? mkdir(ignoredRoot, { recursive: true }) : Promise.resolve(),
mkdir(trackedRoot, { recursive: true }),
]);
for (let index = 0; index < DIRECTORY_COUNT; index += 1) {
@@ -135,7 +205,7 @@ async function measure(run: number): Promise<Measurement> {
firstObservedAt.set(event.path, performance.now());
}
},
{ ignore: [ignoredRoot] },
{ ignore: MODE === "healthy" ? [ignoredRoot] : [] },
),
),
);
@@ -144,7 +214,40 @@ async function measure(run: number): Promise<Measurement> {
const startupEvents = delivered.length;
const kernelWatchCount = await readKernelWatchCount();
let peakPendingClassificationCount = 0;
let lateIgnoredTrackedFileCount: number | null = null;
let samplePending: ReturnType<typeof setInterval> | undefined;
try {
samplePending = setInterval(() => {
peakPendingClassificationCount = Math.max(
peakPendingClassificationCount,
observer.getDiagnostics().pendingClassificationCount,
);
}, 50);
samplePending.unref();
if (MODE === "late-ignored") {
// The whole point: the ignored tree appears only after the observation
// exists. Construction runs in concurrent batches (not one
// mkdir/writeFile at a time) so filesystem events arrive faster than
// the observer can classify them — otherwise nothing ever piles up.
for (const target of roots) {
await mkdir(target.ignoredRoot, { recursive: true });
await runInBatches(LATE_IGNORED_DIRS, LATE_IGNORED_BATCH_SIZE, (index) =>
mkdir(join(target.ignoredRoot, `package-${index}`), { recursive: true }),
);
await runInBatches(LATE_IGNORED_FILES, LATE_IGNORED_BATCH_SIZE, (index) =>
writeFile(
join(target.ignoredRoot, `package-${index % LATE_IGNORED_DIRS}`, `module-${index}.js`),
`${index}\n`,
),
);
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
lateIgnoredTrackedFileCount = observer.getDiagnostics().nativeTrackedFileCount;
}
for (let index = 0; index < IGNORED_FILE_COUNT; index += 1) {
const target = roots[index % roots.length];
await writeFile(join(target.ignoredRoot, `artifact-${index}.txt`), `${index}\n`);
@@ -263,6 +366,8 @@ async function measure(run: number): Promise<Measurement> {
sustainedMissedPaths: sustainedPaths.filter(
(path) => !delivered.some((event) => event.path === path),
).length,
lateIgnoredTrackedFileCount,
peakPendingClassificationCount,
teardownMs,
startupEvents,
deliveredEvents: delivered.length,
@@ -282,14 +387,307 @@ async function measure(run: number): Promise<Measurement> {
diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount,
fullReconciliationCount:
diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount,
maxReconciliationDurationMs: diagnosticsAfter.maxReconciliationDurationMs,
lastReconciliationDurationMs: diagnosticsAfter.lastReconciliationDurationMs,
nativeRenameEventCount: null,
churnDirectoryCount: null,
churnFileCount: null,
churnRounds: null,
churnDirectoriesChurned: null,
};
} finally {
if (samplePending) clearInterval(samplePending);
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
await observer.close();
await rm(base, { recursive: true, force: true });
}
}
// directory-churn mode. Unlike healthy/late-ignored, this builds one large
// tracked tree and then repeatedly makes whole known directories disappear —
// the only way to invoke removeSubtree, reconcileSubtree, and paths.collapse
// in native-recursive.ts.
async function measureDirectoryChurn(run: number): Promise<Measurement> {
const totalChurnTargets = CHURN_ROUNDS * CHURN_DIRECTORIES_PER_ROUND;
if (totalChurnTargets > CHURN_DIRECTORY_COUNT) {
throw new Error(
`PASEO_WATCH_BENCH_CHURN_ROUNDS (${CHURN_ROUNDS}) * ` +
`PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND (${CHURN_DIRECTORIES_PER_ROUND}) = ` +
`${totalChurnTargets} must not exceed PASEO_WATCH_BENCH_CHURN_DIRECTORIES ` +
`(${CHURN_DIRECTORY_COUNT})`,
);
}
const base = await mkdtemp(join(tmpdir(), "paseo-watch-churn-"));
const root = join(base, "root");
await mkdir(root, { recursive: true });
const observer = createFileObserver();
let subscription: FileObserverSubscription | null = null;
try {
// 1. Build a large, entirely-tracked tree before subscribing. Nothing
// here is ignored: removeSubtree and reconcileSubtree scale with the size
// of `this.files` / `this.directories`, so the fixture needs a big
// tracked set for their cost to show up at all.
await runInBatches(CHURN_DIRECTORY_COUNT, CHURN_BATCH_SIZE, (index) =>
mkdir(join(root, `dir-${index}`)),
);
await runInBatches(
CHURN_DIRECTORY_COUNT * CHURN_FILES_PER_DIRECTORY,
CHURN_BATCH_SIZE,
(flatIndex) => {
const directoryIndex = Math.floor(flatIndex / CHURN_FILES_PER_DIRECTORY);
const fileIndex = flatIndex % CHURN_FILES_PER_DIRECTORY;
return writeFile(
join(root, `dir-${directoryIndex}`, `file-${fileIndex}.txt`),
`${flatIndex}\n`,
);
},
);
const rssBefore = process.memoryUsage().rss;
const cpuBefore = process.cpuUsage();
const diagnosticsBefore = observer.getDiagnostics();
const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
eventLoopDelay.enable();
const delivered: FileChange[] = [];
let error: Error | null = null;
const setupStarted = performance.now();
subscription = await observer.subscribe(root, (nextError, events) => {
error ??= nextError;
delivered.push(...events);
});
const setupMs = performance.now() - setupStarted;
// 2. Let the initial inventory settle before churning anything.
const initialSettled = await waitFor(() => {
const diagnostics = observer.getDiagnostics();
return (
diagnostics.pendingEventCount === 0 &&
diagnostics.pendingReconciliationWorkCount === 0 &&
diagnostics.reconciliationInFlightCount === 0
);
}, 120_000);
if (!initialSettled) {
throw new Error("Timed out waiting for directory-churn initial inventory to settle");
}
if (error) throw error;
const startupEvents = delivered.length;
const kernelWatchCount = await readKernelWatchCount();
// 3. Drive directory churn. Each round deletes half of a batch of known
// directories outright (their parent's next shallow scan finds them gone
// and calls removeSubtree) and replaces the other half atomically in
// place: rm the old directory, then rename a freshly built sibling onto
// the same path. The backend's classify() stat() resolves after the
// rename completes, sees a directory at a path it already knew as a
// directory, and treats it as the directory itself changing —
// populating recursiveScopes and driving reconcileSubtree. Firing every
// round's operations concurrently, faster than the audit's 500ms quiet
// window, is what forces the resulting scopes onto one large
// reconciliation instead of many small ones, which is what the
// production incident this mode approximates looked like.
const diagnosticsBeforeChurn = observer.getDiagnostics();
const churnCpuBefore = process.cpuUsage();
const churnStarted = performance.now();
let nextDirectoryIndex = 0;
for (let round = 0; round < CHURN_ROUNDS; round += 1) {
const batch: number[] = [];
for (let i = 0; i < CHURN_DIRECTORIES_PER_ROUND; i += 1) {
batch.push(nextDirectoryIndex);
nextDirectoryIndex += 1;
}
const deleteCount = Math.floor(batch.length / 2);
const deleteTargets = batch.slice(0, deleteCount);
const replaceTargets = batch.slice(deleteCount);
await runInBatches(deleteTargets.length, CHURN_BATCH_SIZE, (offset) =>
rm(join(root, `dir-${deleteTargets[offset]}`), { recursive: true, force: true }),
);
await runInBatches(replaceTargets.length, CHURN_BATCH_SIZE, (offset) =>
replaceDirectoryAtomically(
join(root, `dir-${replaceTargets[offset]}`),
CHURN_FILES_PER_DIRECTORY,
),
);
}
const churnSettled = await waitFor(() => {
const diagnostics = observer.getDiagnostics();
return (
diagnostics.pendingEventCount === 0 &&
diagnostics.pendingReconciliationWorkCount === 0 &&
diagnostics.reconciliationInFlightCount === 0
);
}, CHURN_SETTLE_TIMEOUT_MS);
if (!churnSettled) {
throw new Error(
`Timed out waiting for directory-churn reconciliation to settle within ` +
`${CHURN_SETTLE_TIMEOUT_MS}ms`,
);
}
const churnDurationMs = performance.now() - churnStarted;
const churnCpu = process.cpuUsage(churnCpuBefore);
const churnCpuMs = (churnCpu.user + churnCpu.system) / 1_000;
if (error) throw error;
const diagnosticsAfterChurn = observer.getDiagnostics();
const teardownStarted = performance.now();
await subscription.unsubscribe();
const teardownMs = performance.now() - teardownStarted;
const diagnosticsAfter = observer.getDiagnostics();
const cpu = process.cpuUsage(cpuBefore);
eventLoopDelay.disable();
return {
backend: "production",
run,
setupMs,
editLatencyMs: 0,
editLatencyP50Ms: 0,
editLatencyP95Ms: 0,
editLatencyP99Ms: 0,
burstDurationMs: churnDurationMs,
burstCpuMs: churnCpuMs,
sustainedDurationMs: 0,
sustainedCpuMs: 0,
sustainedScopedReconciliationCount: null,
sustainedFullReconciliationCount: null,
sustainedNativeEventCount: null,
sustainedNativeChangeEventCount: null,
sustainedNativeRenameEventCount: null,
sustainedNativePathlessEventCount: null,
sustainedNativeClassificationCount: null,
sustainedNativeShallowScanCount: null,
sustainedMissedPaths: 0,
lateIgnoredTrackedFileCount: null,
peakPendingClassificationCount: 0,
teardownMs,
startupEvents,
deliveredEvents: delivered.length,
// Nothing is ignored in this mode's fixture; see the comment at the
// CHURN_DIRECTORY_COUNT declaration above.
ignoredEvents: 0,
missedTrackedPaths: 0,
cpuUserMs: cpu.user / 1_000,
cpuSystemMs: cpu.system / 1_000,
rssDeltaMiB: (process.memoryUsage().rss - rssBefore) / 1024 / 1024,
eventLoopDelayP99Ms: eventLoopDelay.percentile(99) / 1_000_000,
eventLoopDelayMaxMs: eventLoopDelay.max / 1_000_000,
kernelWatchCount,
scopedReconciliationCount:
diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount,
fullReconciliationCount:
diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount,
maxReconciliationDurationMs: diagnosticsAfter.maxReconciliationDurationMs,
lastReconciliationDurationMs: diagnosticsAfter.lastReconciliationDurationMs,
nativeRenameEventCount:
diagnosticsAfterChurn.nativeRenameEventCount -
diagnosticsBeforeChurn.nativeRenameEventCount,
churnDirectoryCount: CHURN_DIRECTORY_COUNT,
churnFileCount: CHURN_DIRECTORY_COUNT * CHURN_FILES_PER_DIRECTORY,
churnRounds: CHURN_ROUNDS,
churnDirectoriesChurned: totalChurnTargets,
};
} finally {
if (subscription) await subscription.unsubscribe();
await observer.close();
await rm(base, { recursive: true, force: true });
}
}
// Builds a fresh sibling directory, then rm+rename it onto `directory`. This
// is the atomic-replace pattern dev-server builds use to swap output
// directories in place, and the specific shape that drives reconcileSubtree:
// see the comment inside measureDirectoryChurn's churn loop above.
async function replaceDirectoryAtomically(directory: string, fileCount: number): Promise<void> {
const staging = `${directory}.replacement`;
await mkdir(staging);
for (let fileIndex = 0; fileIndex < fileCount; fileIndex += 1) {
await writeFile(join(staging, `file-${fileIndex}.txt`), `${fileIndex}\n`);
}
await rm(directory, { recursive: true, force: true });
await rename(staging, directory);
}
function resolveLateIgnoredBatchSize(): number {
// A batch this small still produces a genuine burst instead of degenerating
// back toward the sequential, one-at-a-time construction this code
// replaces — see the comment on LATE_IGNORED_BATCH_SIZE above for why a
// burst is required at all.
const floor = 64;
// Never exceed this regardless of how generous the descriptor limit is.
// 2,000 already produces peak pending classifications in the ~2,700-3,200
// range on a high-limit machine, comfortably clearing the 2,080 bound the
// later fix imposes, so there is no reason to risk more file descriptors
// for a larger, noisier number.
const ceiling = 2_000;
// Used when this runtime cannot report its own descriptor limit at all.
// Small enough to be safe under a default Linux/CI soft limit of roughly
// 1,024-4,096, still large enough to produce a real burst.
const fallback = 512;
const softLimit = readOpenFileSoftLimit();
if (softLimit === null) return fallback;
// Leave headroom for everything else the process holds open at the same
// time: stdio, the fixture's own already-created directories/files, the
// observer's native watch handle, and Node's own bookkeeping fds.
const quarter = Math.floor(softLimit / 4);
return Math.min(ceiling, Math.max(floor, quarter));
}
function readOpenFileSoftLimit(): number | null {
// Node's diagnostic report is the only cross-platform way to read this
// process's own open-file (RLIMIT_NOFILE) soft limit without shelling out
// to `ulimit -n`, which is a shell builtin with no direct Node API.
// `header.fileDescriptorSoftLimit` is checked first for forward/backward
// compatibility, but it does not exist on this script's tested runtime
// (verified empirically: `undefined` on Node v24.15.0/darwin). The field
// that does exist there, and is expected to exist on Linux too since it
// comes from the same libuv getrlimit() call, is
// `userLimits.open_files.soft`.
let report: unknown;
try {
report = process.report?.getReport();
} catch {
return null;
}
if (typeof report !== "object" || report === null) return null;
const asRecord = report as Record<string, unknown>;
return (
parseDescriptorLimit(readNestedField(asRecord, ["header", "fileDescriptorSoftLimit"])) ??
parseDescriptorLimit(readNestedField(asRecord, ["userLimits", "open_files", "soft"]))
);
}
function readNestedField(source: Record<string, unknown>, path: string[]): unknown {
let current: unknown = source;
for (const key of path) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
function parseDescriptorLimit(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
if (typeof value === "string" && value.trim().toLowerCase() === "unlimited") {
return Number.POSITIVE_INFINITY;
}
return null;
}
async function runInBatches(
count: number,
batchSize: number,
createEntry: (index: number) => Promise<unknown>,
): Promise<void> {
for (let start = 0; start < count; start += batchSize) {
const end = Math.min(start + batchSize, count);
const batch: Promise<unknown>[] = [];
for (let index = start; index < end; index += 1) batch.push(createEntry(index));
await Promise.all(batch);
}
}
async function readKernelWatchCount(): Promise<number | null> {
if (process.platform !== "linux") return null;
let count = 0;
@@ -311,6 +709,11 @@ function fixtureDescription() {
sustainedEdits: SUSTAINED_EDIT_COUNT,
sustainedEditIntervalMs: SUSTAINED_EDIT_INTERVAL_MS,
repetitions: REPETITIONS,
mode: MODE,
churnDirectories: CHURN_DIRECTORY_COUNT,
churnFilesPerDirectory: CHURN_FILES_PER_DIRECTORY,
churnRounds: CHURN_ROUNDS,
churnDirectoriesPerRound: CHURN_DIRECTORIES_PER_ROUND,
platform: process.platform,
arch: process.arch,
node: process.version,
@@ -321,6 +724,12 @@ function evaluate(results: Measurement[]): {
passed: boolean;
failures: string[];
} {
if (MODE === "directory-churn") {
// This phase exists to characterize a known-bad code path (removeSubtree
// and reconcileSubtree scanning the whole tracked set per disappeared
// directory), not to gate CI on a threshold yet.
return { passed: true, failures: [] };
}
const failures: string[] = [];
for (const result of results) {
evaluateStandaloneResult(result, failures);
@@ -332,7 +741,10 @@ function evaluateStandaloneResult(result: Measurement, failures: string[]): void
if (result.missedTrackedPaths !== 0) {
failures.push(`observer missed ${result.missedTrackedPaths} tracked paths`);
}
if (result.ignoredEvents !== 0) {
// In late-ignored mode, nonzero ignoredEvents is the bug reproducing, not a
// regression: the observer is deliberately given the ignored tree only
// after it subscribes, so it has no way to exclude it yet.
if (MODE !== "late-ignored" && result.ignoredEvents !== 0) {
failures.push(`observer emitted ${result.ignoredEvents} excluded events`);
}
if (result.sustainedMissedPaths !== 0) {
@@ -1,5 +1,5 @@
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import {
acquirePidLock,
@@ -9,6 +9,8 @@ import {
updatePidLock,
} from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { daemonLogPath } from "../src/server/daemon-instance.js";
import { PRIVATE_FILE_MODE } from "../src/server/private-files.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
@@ -118,9 +120,7 @@ async function main(): Promise<void> {
});
} catch (error) {
if (error instanceof PidLockError) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
return;
failStartup(error.message, error.message);
}
throw error;
}
@@ -172,17 +172,41 @@ async function main(): Promise<void> {
: undefined,
restartOnCrash: true,
logFile: supervisorLogFile,
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
onWorkerReady: async ({ listen, serverId }) => {
await updatePidLock(paseoHome, { listen, serverId }, { ownerPid: process.pid });
},
onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }),
onWorkerExit: () =>
updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }),
onSupervisorExit: releaseLock,
});
requestSupervisorShutdown = supervisor.requestShutdown;
}
void main().catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
// The supervisor opens its log only after config and the PID lock succeed. A background
// launch discards stderr, so earlier failures also go to the log the launcher points at.
function failStartup(detail: string, summary: string): never {
process.stderr.write(`${detail}\n`);
try {
const logPath = daemonLogPath(resolvePaseoHome(process.env));
mkdirSync(path.dirname(logPath), { recursive: true });
appendFileSync(
logPath,
`${JSON.stringify({
level: "fatal",
time: new Date().toISOString(),
pid: process.pid,
name: "DaemonRunner",
msg: summary,
})}\n`,
{ mode: PRIVATE_FILE_MODE },
);
} catch {
// stderr already carries the failure.
}
process.exit(1);
}
void main().catch((error) => {
if (error instanceof Error) failStartup(error.stack ?? error.message, error.message);
failStartup(String(error), String(error));
});
@@ -38,7 +38,7 @@ async function runSupervisorFixture(options: {
await writeFile(
workerPath,
`process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`,
`process.send?.({ type: "paseo:ready", listen: "fixture", serverId: "srv_fixture" });\n${options.workerSource}`,
);
await writeFile(
runnerPath,
@@ -92,7 +92,7 @@ test("a replacement worker must independently reach ready", async () => {
process.on("message", message => {
if (message.type === "paseo:graceful-shutdown") process.exit(0);
});
process.send({ type: "paseo:ready", listen: "test-endpoint" });
process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" });
process.send({ type: "paseo:restart" });
`);
expect(result.code).toBe(1);
@@ -102,7 +102,7 @@ test("a replacement worker must independently reach ready", async () => {
test("exit clears publication after an in-flight ready write", async () => {
const result = await runWorker(
`
process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0));
process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }, () => process.exit(0));
`,
100,
);
+13 -4
View File
@@ -23,6 +23,7 @@ type WorkerLifecycleMessage =
| {
type: "paseo:ready";
listen: string;
serverId: string;
}
| {
type: "paseo:restart";
@@ -50,7 +51,7 @@ interface SupervisorOptions {
args: string[];
env?: NodeJS.ProcessEnv;
} | null;
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
onWorkerReady?: (message: { listen: string; serverId: string }) => Promise<void> | void;
onWorkerExit?: () => Promise<void> | void;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void;
@@ -78,11 +79,14 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
};
}
if (type === "paseo:ready") {
const listen = (msg as { listen?: unknown }).listen;
const { listen, serverId } = msg as { listen?: unknown; serverId?: unknown };
if (typeof listen !== "string" || listen.trim().length === 0) {
return null;
}
return { type: "paseo:ready", listen };
if (typeof serverId !== "string" || serverId.trim().length === 0) {
return null;
}
return { type: "paseo:ready", listen, serverId };
}
if (type === "paseo:restart") {
const reason = (msg as { reason?: unknown }).reason;
@@ -289,7 +293,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
reachedReady = true;
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
publication = publication
.then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen }))
.then(() =>
options.onWorkerReady?.({
listen: lifecycleMessage.listen,
serverId: lifecycleMessage.serverId,
}),
)
.catch((error) => {
lifecycleFailed = true;
const message = error instanceof Error ? error.message : String(error);
@@ -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 });
}
});
@@ -692,6 +692,137 @@ test("uses an injected timeline store without making it a production requirement
}
});
test("refreshing an agent replaces the injected timeline store instead of appending a second copy", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rehydrate-"));
const store = new RecordingTimelineStore();
class HistorySession extends TestAgentSession {
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: "continue" },
};
yield {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "done" },
};
}
}
class HistoryClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new HistorySession(config);
}
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new HistorySession({ provider: "codex", cwd: config?.cwd ?? workdir });
}
}
const manager = new AgentManager({
clients: { codex: new HistoryClient() },
durableTimelineStore: store,
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
// What session.handleRefreshAgentRequest does for a loaded agent, twice over.
for (let refresh = 0; refresh < 2; refresh += 1) {
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
await manager.flush();
}
const rows = await manager.getTimelineRows(agent.id);
expect(rows.map((row) => row.item)).toEqual([
{ type: "user_message", text: "continue" },
{ type: "assistant_message", text: "done" },
]);
} finally {
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("a failed history replay leaves the committed timeline intact", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-replay-fail-"));
const store = new RecordingTimelineStore();
let failReplay = false;
class FlakyHistorySession extends TestAgentSession {
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: "continue" },
};
if (failReplay) {
throw new Error("history stream closed");
}
yield {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "done" },
};
}
}
class FlakyHistoryClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new FlakyHistorySession(config);
}
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new FlakyHistorySession({ provider: "codex", cwd: config?.cwd ?? workdir });
}
}
const manager = new AgentManager({
clients: { codex: new FlakyHistoryClient() },
durableTimelineStore: store,
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
await manager.flush();
const committed = await manager.getTimelineRows(agent.id);
failReplay = true;
await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true });
await expect(
manager.hydrateTimelineFromProvider(agent.id, { broadcast: true }),
).rejects.toThrow("history stream closed");
await manager.flush();
expect(await manager.getTimelineRows(agent.id)).toEqual(committed);
// A retry once the provider recovers replaces that history rather than stacking on it.
failReplay = false;
await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true });
await manager.flush();
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
{ type: "user_message", text: "continue" },
{ type: "assistant_message", text: "done" },
]);
} finally {
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("retries provider history hydration after a stream failure", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-retry-"));
let attempts = 0;
@@ -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,
},
);
@@ -3996,23 +4025,17 @@ export class AgentManager {
broadcast: boolean | (() => boolean),
): Promise<void> {
const deferredBroadcast = typeof broadcast === "function";
const timelineEvents: Array<{
event: Extract<AgentStreamEvent, { type: "timeline" }>;
row: AgentTimelineRow;
}> = [];
const providerSubagentEvents: AgentManagerEvent[] = [];
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
const historySubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
agent.historyPrimed = false;
try {
// Collect the whole replay before touching either store. A stream that fails
// halfway then leaves the committed timeline as it was, instead of a partial
// copy the next attempt would append to.
for await (const rawEvent of agent.session.streamHistory()) {
const event = limitAgentStreamEventContent(rawEvent);
if (event.type === "provider_subagent") {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
if (deferredBroadcast) {
providerSubagentEvents.push(managerEvent);
} else if (broadcast) {
this.dispatch(managerEvent);
}
historySubagentEvents.push(event);
continue;
}
if (event.type !== "timeline") {
@@ -4021,25 +4044,47 @@ export class AgentManager {
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
continue;
}
const row = this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (deferredBroadcast) {
timelineEvents.push({ event, row });
} else if (broadcast) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
historyEvents.push(event);
}
} catch (error) {
this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history");
throw error;
}
// The replay is the timeline, so drop the rows a previous hydration committed.
// Keeping them would leave getTimelineRows reading one copy per hydration.
await this.deleteCommittedTimeline(agent.id);
const timelineEvents: Array<{
event: Extract<AgentStreamEvent, { type: "timeline" }>;
row: AgentTimelineRow;
}> = [];
const providerSubagentEvents: AgentManagerEvent[] = [];
for (const event of historySubagentEvents) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
if (deferredBroadcast) {
providerSubagentEvents.push(managerEvent);
} else if (broadcast) {
this.dispatch(managerEvent);
}
}
for (const event of historyEvents) {
const row = this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (deferredBroadcast) {
timelineEvents.push({ event, row });
} else if (broadcast) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
agent.historyPrimed = true;
if (typeof broadcast !== "function" || !broadcast()) {
@@ -4999,23 +5044,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 +5121,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 };
@@ -89,6 +89,75 @@ async function createMcpClient(url: string, authToken?: string): Promise<McpClie
return { callTool: boundCallTool, close: () => rawClient.close() };
}
interface OfflineMcpDaemon {
client: McpClient;
stop: () => Promise<void>;
}
async function startOfflineMcpDaemon(): Promise<OfflineMcpDaemon> {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const removeDirectories = async () => {
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
};
const port = await getAvailablePort();
const daemon = await createPaseoDaemon(
{
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
},
pino({ level: "silent" }),
).catch(async (error: unknown) => {
await removeDirectories();
throw error;
});
try {
await daemon.start();
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
return {
client,
stop: async () => {
await client.close();
await daemon.stop();
await removeDirectories();
},
};
} catch (error) {
await daemon.stop();
await removeDirectories();
throw error;
}
}
type WorkspaceCreation = { workspaceId: unknown } | { error: unknown };
async function createLocalWorkspace(client: McpClient, cwd: string): Promise<WorkspaceCreation> {
const result = await client.callTool({
name: "create_workspace",
args: { isolation: "local", path: cwd },
});
if (result.isError) {
const content = result.content?.[0];
return { error: content && "text" in content ? content.text : undefined };
}
return { workspaceId: getStructuredContent(result)?.workspaceId };
}
async function listWorkspaces(client: McpClient): Promise<WorkspaceCreation[]> {
const result = await client.callTool({ name: "list_workspaces", args: {} });
const workspaces = getStructuredContent(result)?.workspaces;
if (!Array.isArray(workspaces)) return [];
return workspaces.map((workspace: StructuredContent) => ({ workspaceId: workspace.workspaceId }));
}
interface LaunchRecorder {
recordedLaunches: AgentSessionConfig[];
}
@@ -235,6 +304,27 @@ describe("agent MCP end-to-end (offline)", () => {
}
}, 30_000);
test("create_workspace with local isolation adopts only an existing directory", async () => {
const daemon = await startOfflineMcpDaemon();
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-workspace-"));
const missingPath = path.join(root, "does-not-exist");
const filePath = path.join(root, "regular-file");
await writeFile(filePath, "not a directory\n", "utf8");
try {
expect(await createLocalWorkspace(daemon.client, missingPath)).toEqual({
error: expect.stringContaining(`Directory not found: ${missingPath}`),
});
expect(await createLocalWorkspace(daemon.client, filePath)).toEqual({
error: expect.stringContaining(`Directory not found: ${filePath}`),
});
const created = await createLocalWorkspace(daemon.client, root);
expect(await listWorkspaces(daemon.client)).toEqual([created]);
} finally {
await daemon.stop();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
test("password-protected daemon authorizes the agent MCP via the capability token", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
@@ -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;
}
/**
@@ -737,6 +737,7 @@ function createPaseoWorktreeForMcpTest(options: {
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
logger: createTestLogger(),
});
const workspaceAutoName = new WorkspaceAutoName({
@@ -4,9 +4,10 @@ import type {
ProviderInput,
ProviderRegistration,
} from "@getpaseo/plugin/server/provider";
import { setImmediate as nextTurn } from "node:timers/promises";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js";
import { PluginAgentClientRegistry } from "./plugin-provider.js";
import {
isStaleProviderSessionError,
@@ -25,6 +26,7 @@ interface ProviderHarnessOptions {
capabilities?: ProviderConnection["capabilities"];
completeTurn?: boolean;
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
function createProviderHarness(options: ProviderHarnessOptions = {}) {
@@ -43,6 +45,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
capabilities,
async send(input) {
inputs.push(input);
if (await options.handleInput?.(input, emit)) return;
if (input.type === "catalog") {
emit({
type: "catalog",
@@ -278,6 +281,59 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
}
describe("PluginAgentClientRegistry", () => {
test.each([false, true])(
"contains a failed session open while send is pending: %s",
async (pendingSend) => {
let listener: ((event: ProviderEvent) => void) | undefined;
const unhandled: unknown[] = [];
const observeUnhandled = (reason: unknown) => unhandled.push(reason);
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([
{
id: "failing-provider",
label: "Failing provider",
async connect() {
return {
version: 1,
capabilities: ["session.persistence"],
async send(input) {
if (input.type !== "session.open") return;
listener?.({
type: "request.failed",
requestId: input.requestId,
error: { message: "OMP persistent session registration is in progress" },
});
// Keep acceptance pending across a Node event-loop turn, as IPC can do.
if (pendingSend) await nextTurn();
},
onEvent(nextListener) {
listener = nextListener;
return () => {
listener = undefined;
};
},
async close() {},
};
},
},
]);
process.on("unhandledRejection", observeUnhandled);
try {
await expect(
registry.clients()["failing-provider"]!.createSession({
provider: "failing-provider",
cwd: "/workspace",
}),
).rejects.toThrow("OMP persistent session registration is in progress");
await nextTurn();
expect(unhandled).toEqual([]);
} finally {
await registry.shutdown();
process.off("unhandledRejection", observeUnhandled);
}
},
);
test("preserves nested provider child ownership during opening", async () => {
const harness = createProviderHarness({ openChildren: openNestedChildren });
const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -540,3 +596,167 @@ describe("PluginAgentClientRegistry", () => {
expect(harness.inputs.map((input) => input.type)).toContain("session.close");
});
});
type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt";
async function requestFromClient(client: AgentClient, kind: RequestKind): Promise<unknown> {
if (kind === "catalog") return client.fetchCatalog({ scope: "global" });
const session = await client.createSession({ provider: client.provider, cwd: "/workspace" });
if (kind === "session.open") return session;
if (kind === "session.configure") return session.setMode("build");
return session.startTurn("hello", { clientMessageId: "pending-message" });
}
async function withObservedProvider(
options: ProviderHarnessOptions,
run: (client: AgentClient, registry: PluginAgentClientRegistry) => Promise<void>,
): Promise<void> {
const harness = createProviderHarness(options);
const registry = new PluginAgentClientRegistry(createTestLogger());
const unhandled: unknown[] = [];
const observe = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", observe);
registry.replace([harness.registration]);
try {
await run(registry.clients()[harness.registration.id]!, registry);
await registry.shutdown();
await nextTurn();
expect(unhandled).toEqual([]);
} finally {
await registry.shutdown();
process.off("unhandledRejection", observe);
}
}
describe("pending provider responses", () => {
test.each<RequestKind>(["session.open", "catalog", "session.configure", "session.prompt"])(
"preserves the send error for %s",
async (kind) => {
const failure = new Error("Provider send failed");
await withObservedProvider(
{
async handleInput(input) {
if (input.type === kind) throw failure;
return false;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toBe(failure);
},
);
},
);
test.each([
[
"catalog",
expect.objectContaining({
models: [expect.objectContaining({ id: "plugin-model", isDefault: true })],
defaultModeId: "build",
}),
],
["session.configure", undefined],
] as const)(
"contains an early request.failed for %s and permits a successful retry",
async (kind, result) => {
let failed = false;
await withObservedProvider(
{
async handleInput(input, emit) {
if (input.type !== kind || !("requestId" in input) || failed) return false;
failed = true;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "Provider request failed", code: "busy", diagnostic: "retry" },
});
await nextTurn();
return true;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toMatchObject({
message: "Provider request failed",
code: "busy",
diagnostic: "retry",
});
await expect(requestFromClient(client, kind)).resolves.toEqual(result);
},
);
},
);
test.each([
["session.open", "Provider session closed"],
["session.configure", "Provider session closed"],
["session.prompt", StaleProviderSessionError],
] as const)("contains session closure while accepting %s", async (kind, error) => {
await withObservedProvider(
{
async handleInput(input, emit) {
if (input.type !== kind || !("sessionId" in input)) return false;
emit({
type: "session.closed",
sessionId: input.sessionId,
error: { message: "Provider session closed" },
});
await nextTurn();
return true;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
},
);
});
test.each([
["session.open", "Provider connection closed"],
["catalog", "Provider closed"],
["session.configure", "Provider closed"],
["session.prompt", StaleProviderSessionError],
] as const)("contains connection shutdown while accepting %s", async (kind, error) => {
let disconnect!: () => Promise<void>;
await withObservedProvider(
{
async handleInput(input) {
if (input.type !== kind) return false;
await disconnect();
await nextTurn();
return true;
},
},
async (client, registry) => {
disconnect = () => registry.shutdown();
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
},
);
});
test.each([true, false])(
"preserves cancellation with acceptance pending: %s",
async (pending) => {
const controller = new AbortController();
const reason = new Error("Catalog refresh cancelled");
await withObservedProvider(
{
async handleInput(input) {
if (input.type !== "catalog") return false;
if (pending) {
controller.abort(reason);
await nextTurn();
} else {
setImmediate(() => controller.abort(reason));
}
return true;
},
},
async (client) => {
await expect(
client.fetchCatalog({ scope: "global" }, { signal: controller.signal }),
).rejects.toBe(reason);
},
);
},
);
});
@@ -92,6 +92,9 @@ function deferred<Value>(): Deferred<Value> {
resolve = onResolve;
reject = onReject;
});
// Provider events can reject before send() settles and the caller awaits this promise.
// Observe that interval without replacing the rejecting promise returned to the caller.
void promise.catch(() => undefined);
return { promise, resolve, reject };
}
@@ -51,6 +51,7 @@ describe("provider-owned option schemas", () => {
ClaudeProviderOptionsSchema.parse({
allowedTools: ["Read"],
disallowedTools: ["Bash(rm *)"],
extraArgs: { chrome: null, model: "x" },
sandbox: {
enabled: true,
failIfUnavailable: true,
@@ -63,7 +64,26 @@ describe("provider-owned option schemas", () => {
},
settings: { permissions: { ask: ["Bash(*)"], deny: ["Edit(.env)"] } },
}),
).toMatchObject({ sandbox: { enabled: true, failIfUnavailable: true } });
).toMatchObject({
extraArgs: { chrome: null, model: "x" },
sandbox: { enabled: true, failIfUnavailable: true },
});
});
test.each([true, 42, ["x"]])("rejects invalid Claude extra argument values: %j", (value) => {
expect(() =>
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
extraArgs: { chrome: value },
}),
).toThrow("providerOptions.extraArgs.chrome");
});
test("rejects a raw argument list for the SDK argument map", () => {
expect(() =>
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
extraArgs: ["--chrome"],
}),
).toThrow("providerOptions.extraArgs");
});
test("reports the exact invalid Claude option path", () => {
@@ -8,6 +8,7 @@ import {
RequestError,
ndJsonStream,
type Agent,
type CreateTerminalRequest,
PermissionOption,
PromptResponse,
RequestPermissionRequest,
@@ -128,7 +129,13 @@ interface ACPConfiguredOverrideInternals {
applyConfiguredOverrides(): Promise<void>;
}
function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession {
function createSession(
options: {
terminateProcess?: ProcessTerminator;
launchEnv?: Record<string, string>;
} = {},
): ACPAgentSession {
const { terminateProcess, launchEnv } = options;
return new ACPAgentSession(
{
provider: "claude-acp",
@@ -148,6 +155,7 @@ function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession {
supportsToolInvocations: true,
},
...(terminateProcess ? { terminateProcess } : {}),
...(launchEnv ? { launchEnv } : {}),
},
);
}
@@ -660,6 +668,114 @@ describe("ACPAgentSession terminal tools", () => {
);
});
// Terminals the daemon opens for an agent are siblings of the agent process, so
// they inherit nothing from it. They carry the agent's identity only if the
// session puts its launch environment on them, the way its transport spawn does.
describe("agent launch environment", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
const readLaunchIdentityArgs = [
"-e",
"process.stdout.write(JSON.stringify({ id: process.env.PASEO_AGENT_ID ?? null, cwd: process.env.PASEO_AGENT_CWD ?? null, extra: process.env.PASEO_TEST_EXTRA ?? null }))",
];
async function readLaunchIdentity(
session: ACPAgentSession,
params: Partial<CreateTerminalRequest> = {},
): Promise<{ id: string | null; cwd: string | null; extra: string | null }> {
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: readLaunchIdentityArgs,
cwd: process.cwd(),
...params,
});
await session.waitForTerminalExit({
sessionId: "session-1",
terminalId: terminal.terminalId,
});
const { output } = await session.terminalOutput({
sessionId: "session-1",
terminalId: terminal.terminalId,
});
return JSON.parse(output.trim());
}
test("gives the terminal the agent's identity", async () => {
// The daemon itself does not run inside a Paseo agent.
vi.stubEnv("PASEO_AGENT_ID", undefined);
vi.stubEnv("PASEO_AGENT_CWD", undefined);
const session = createSession({
launchEnv: {
PASEO_AGENT_ID: "agent-1",
PASEO_AGENT_CWD: "/repo",
},
});
await expect(readLaunchIdentity(session)).resolves.toMatchObject({
id: "agent-1",
cwd: "/repo",
});
});
test("prefers the agent's identity over the daemon's own environment", async () => {
// A daemon started from inside another Paseo agent carries that agent's id.
vi.stubEnv("PASEO_AGENT_ID", "daemon-host-agent");
vi.stubEnv("PASEO_AGENT_CWD", "/elsewhere");
const session = createSession({
launchEnv: {
PASEO_AGENT_ID: "agent-1",
PASEO_AGENT_CWD: "/repo",
},
});
await expect(readLaunchIdentity(session)).resolves.toMatchObject({
id: "agent-1",
cwd: "/repo",
});
});
test("lets the requested terminal environment win over the launch environment", async () => {
const session = createSession({
launchEnv: {
PASEO_AGENT_ID: "agent-1",
PASEO_TEST_EXTRA: "from-launch",
},
});
await expect(
readLaunchIdentity(session, {
env: [{ name: "PASEO_TEST_EXTRA", value: "from-request" }],
}),
).resolves.toMatchObject({ id: "agent-1", extra: "from-request" });
});
test("carries the agent's identity into single-string shell commands", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession({ launchEnv: { PASEO_AGENT_ID: "agent-1" } });
await session.createTerminal({
sessionId: "session-1",
command: "paseo heartbeat create --every 5m",
cwd: "/repo",
});
expect(spawn).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
envOverlay: expect.objectContaining({
PASEO_AGENT_ID: "agent-1",
BASH_ENV: undefined,
}),
}),
);
});
});
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
const child = createTerminalChildStub();
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
@@ -3327,7 +3443,7 @@ describe("ACPAgentSession close() tree-kill", () => {
test("close() terminates the main child process via the process tree", async () => {
const terminator = new FakeTerminator();
const session = createSession(terminator.terminate);
const session = createSession({ terminateProcess: terminator.terminate });
const internals = asInternals<ACPCloseInternals>(session);
const child = createTerminalChildStub();
@@ -3345,7 +3461,7 @@ describe("ACPAgentSession close() tree-kill", () => {
test("close() terminates running terminal child processes", async () => {
const terminator = new FakeTerminator();
const session = createSession(terminator.terminate);
const session = createSession({ terminateProcess: terminator.terminate });
const terminalChild = createTerminalChildStub();
await startTerminal(session, terminalChild);
@@ -3358,7 +3474,7 @@ describe("ACPAgentSession close() tree-kill", () => {
test("close() terminates terminal child processes in parallel", async () => {
const terminator = new FakeTerminator("deferred");
const session = createSession(terminator.terminate);
const session = createSession({ terminateProcess: terminator.terminate });
const firstChild = createTerminalChildStub();
const secondChild = createTerminalChildStub();
@@ -3376,7 +3492,7 @@ describe("ACPAgentSession close() tree-kill", () => {
test("killTerminal terminates the terminal process tree without a direct SIGTERM", async () => {
const terminator = new FakeTerminator();
const session = createSession(terminator.terminate);
const session = createSession({ terminateProcess: terminator.terminate });
const child = createTerminalChildStub();
const terminalId = await startTerminal(session, child);
@@ -3389,7 +3505,7 @@ describe("ACPAgentSession close() tree-kill", () => {
test("releaseTerminal terminates and removes a running terminal", async () => {
const terminator = new FakeTerminator();
const session = createSession(terminator.terminate);
const session = createSession({ terminateProcess: terminator.terminate });
const child = createTerminalChildStub();
const terminalId = await startTerminal(session, child);
@@ -144,6 +144,10 @@ function isACPError(value: unknown): value is ACPError {
return isRecord(value) && typeof value.message === "string" && typeof value.code === "number";
}
function isACPInvalidParams(error: unknown): boolean {
return isACPError(error) && error.code === -32602;
}
function extractACPErrorDataMessage(data: unknown): string | null {
if (!isRecord(data)) {
return null;
@@ -2277,7 +2281,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (!option) {
throw new Error(`${this.provider} does not expose ACP feature '${featureId}'`);
throw new Error(this.featureUnavailableMessage(featureId));
}
const requestedValue = normalizeConfigFeatureValue(value);
@@ -2614,8 +2618,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
);
const terminalCommand = resolveTerminalCommand(params.command, params.args);
// The terminal is a sibling of the agent process, not a child, so it inherits
// nothing from it. Carry the agent's launch environment the way spawnProcess
// does, keeping the requested terminal environment on top.
const commandEnvOverlays =
terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env];
terminalCommand.shell === false
? [this.launchEnv, env, createStringCommandShellEnvOverlay()]
: [this.launchEnv, env];
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
cwd: params.cwd ?? this.config.cwd,
...createProviderEnvSpec({
@@ -2814,6 +2823,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
await this.setModeWithSelection({ modeId: configuredModeId, selection });
}
const configuredModelId = this.config.model;
let switchedModel = false;
if (configuredModelId && configuredModelId !== this.currentModel) {
const selection = resolveACPModelSelection({
modelId: configuredModelId,
@@ -2822,6 +2832,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
try {
await this.setModelWithSelection({ modelId: configuredModelId, selection });
switchedModel = true;
} catch (error) {
if (!this.isModelSelectionUnavailableError(error)) {
throw error;
@@ -2840,10 +2851,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
continue;
}
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
try {
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
} catch (error) {
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
throw error;
}
this.logger.warn(
{ err: error, featureId: featureOption.id, model: this.currentModel },
`${this.provider} cannot apply ACP feature '${featureOption.id}' to the current model; using the provider default`,
);
}
}
}
/**
* A stored feature value is a preference carried over from whichever model the user
* last configured, so the session it lands on may have no such option. Paseo's own
* guard says so when the session's options are accurate. A model switch answers with
* an empty response, leaving Paseo holding the previous model's options, and then the
* provider is the one that rejects the write as invalid params. Outside those two
* cases the write failed for a reason the user needs to see.
*/
private isStaleFeatureValueError(
error: unknown,
featureId: string,
switchedModel: boolean,
): boolean {
if (this.isFeatureUnavailableError(error, featureId)) {
return true;
}
return switchedModel && isACPInvalidParams(error);
}
private warnInvalidSelection(value: string, message: string): void {
this.logger.warn({ value }, message);
}
@@ -2856,6 +2896,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return error instanceof Error && error.message === this.modelSelectionUnavailableMessage();
}
private featureUnavailableMessage(featureId: string): string {
return `${this.provider} does not expose ACP feature '${featureId}'`;
}
private isFeatureUnavailableError(error: unknown, featureId: string): boolean {
return error instanceof Error && error.message === this.featureUnavailableMessage(featureId);
}
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
if (update.sessionUpdate === "user_message_chunk") {
return this.handleUserMessageChunk(update);
@@ -0,0 +1,269 @@
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession } from "../../agent-sdk-types.js";
const SESSION_ID = "session-1";
/** Claude Code replies to a turn either with an assistant message or with nothing at all. */
type TurnOutcome = { assistantMessageId: string; subagentMessageId?: string } | "no-response";
function initMessage(): Record<string, unknown> {
return {
type: "system",
subtype: "init",
session_id: SESSION_ID,
permissionMode: "default",
model: "claude-sonnet-4-6",
};
}
/** Claude Code echoes the user message back under the uuid Paseo minted for it. */
function userEcho(uuid: string): Record<string, unknown> {
return {
type: "user",
uuid,
session_id: SESSION_ID,
message: { role: "user", content: [{ type: "text", text: "prompt" }] },
};
}
function assistantReply(uuid: string): Record<string, unknown> {
return {
type: "assistant",
uuid,
session_id: SESSION_ID,
message: { id: uuid, role: "assistant", content: [{ type: "text", text: "ok" }] },
};
}
/**
* A message from a Task subagent. The CLI streams these on the same query as the main
* conversation, and their uuids live on the subagent's sidechain, not on the session's
* own message chain, so `forkSession` cannot resolve one.
*/
function subagentReply(uuid: string): Record<string, unknown> {
return {
type: "assistant",
uuid,
session_id: SESSION_ID,
parent_tool_use_id: "toolu_subagent_1",
message: { id: uuid, role: "assistant", content: [{ type: "text", text: "subagent work" }] },
};
}
function successResult(): Record<string, unknown> {
return {
type: "result",
subtype: "success",
uuid: "result-ok",
session_id: SESSION_ID,
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
num_turns: 1,
result: "",
total_cost_usd: 0,
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
permission_denials: [],
};
}
/** A turn that dies upstream before the model emits its first token. */
function failedResult(): Record<string, unknown> {
return {
type: "result",
subtype: "error_during_execution",
uuid: "result-failed",
session_id: SESSION_ID,
duration_ms: 1,
duration_api_ms: 1,
is_error: true,
num_turns: 1,
errors: ["upstream gateway returned 503"],
total_cost_usd: 0,
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 0 },
permission_denials: [],
};
}
interface Conversation {
queryFactory: ReturnType<typeof vi.fn>;
/** The uuid Paseo minted for each prompt, in the order the turns ran. */
userMessageIds: string[];
}
function createConversation(outcomes: TurnOutcome[]): Conversation {
const userMessageIds: string[] = [];
const queryFactory = vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const queued: Array<Record<string, unknown>> = [];
const waiters: Array<() => void> = [];
const closedRef = { value: false };
let turnIndex = 0;
const wake = () => waiters.shift()?.();
const enqueue = (message: Record<string, unknown>) => {
queued.push(message);
wake();
};
void (async () => {
for await (const sent of prompt) {
const userMessageId = String((sent as { uuid?: unknown }).uuid);
userMessageIds.push(userMessageId);
const outcome = outcomes[turnIndex];
turnIndex += 1;
if (turnIndex === 1) {
enqueue(initMessage());
}
enqueue(userEcho(userMessageId));
if (outcome === "no-response" || !outcome) {
enqueue(failedResult());
continue;
}
enqueue(assistantReply(outcome.assistantMessageId));
if (outcome.subagentMessageId) {
enqueue(subagentReply(outcome.subagentMessageId));
}
enqueue(successResult());
}
closedRef.value = true;
wake();
})();
return {
next: vi.fn(async () => {
while (queued.length === 0 && !closedRef.value) {
await new Promise<void>((resolve) => {
waiters.push(resolve);
});
}
if (queued.length === 0) {
return { done: true, value: undefined };
}
return { done: false, value: queued.shift() };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => {
closedRef.value = true;
wake();
return undefined;
}),
close: vi.fn(() => {
closedRef.value = true;
wake();
}),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
});
return { queryFactory, userMessageIds };
}
async function createSession(
conversation: Conversation,
rewindSdk: FakeClaudeSdk,
): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: conversation.queryFactory as never,
resolveBinary: async () => "/test/claude/bin",
rewindSdk,
});
return client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-4-6",
});
}
async function runTurns(session: AgentSession, count: number): Promise<void> {
for (let index = 0; index < count; index += 1) {
for await (const _ of streamSession(session, `turn ${index + 1}`)) {
// drain to the terminal event
}
}
}
describe("Claude rewind across a turn that produced no response", () => {
test("forks at the most recent turn that did produce a response", async () => {
const conversation = createConversation([
{ assistantMessageId: "assistant-1" },
"no-response",
{ assistantMessageId: "assistant-3" },
]);
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 3);
await session.revertConversation?.({ messageId: conversation.userMessageIds[2] });
} finally {
await session.close();
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
});
test("starts a fresh session when no earlier turn produced a response", async () => {
const conversation = createConversation(["no-response", { assistantMessageId: "assistant-2" }]);
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 2);
await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
} finally {
await session.close();
}
expect(rewindSdk.recordedForks).toEqual([]);
});
test("still forks at the previous turn when every turn produced a response", async () => {
const conversation = createConversation([
{ assistantMessageId: "assistant-1" },
{ assistantMessageId: "assistant-2" },
]);
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 2);
await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
} finally {
await session.close();
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
});
});
describe("Claude rewind after a turn whose last assistant message came from a subagent", () => {
test("forks at the turn's own reply, not at the subagent message", async () => {
const conversation = createConversation([
{ assistantMessageId: "assistant-1", subagentMessageId: "subagent-1" },
{ assistantMessageId: "assistant-2" },
]);
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 2);
await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
} finally {
await session.close();
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
});
});
@@ -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 });
@@ -778,6 +785,29 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("passes extra Claude Code CLI arguments to the SDK", async () => {
const { queryFactory, launches } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
providerOptions: {
extraArgs: { chrome: null, model: "x" },
},
});
await expect(session.startTurn("hello")).resolves.toEqual({
turnId: expect.stringMatching(/^foreground-turn-/),
});
expect(launches[0]?.options.extraArgs).toEqual({ chrome: null, model: "x" });
await session.close();
});
test("lists fast mode only for supported Opus models", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
@@ -866,6 +896,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({
@@ -76,7 +76,12 @@ import {
} from "./options.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
import {
realClaudeRewindSdk,
revertClaudeConversation,
revertClaudeFiles,
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
@@ -405,6 +410,7 @@ interface ClaudeAgentClientOptions {
resolveBinary?: () => Promise<string>;
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
configDir?: string;
rewindSdk?: ClaudeRewindSdk;
}
interface ClaudeAgentSessionOptions {
@@ -417,6 +423,7 @@ interface ClaudeAgentSessionOptions {
logger: Logger;
queryFactory?: ClaudeQueryFactory;
resolveBinary: () => Promise<string>;
rewindSdk?: ClaudeRewindSdk;
}
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
@@ -1500,6 +1507,7 @@ export class ClaudeAgentClient implements AgentClient {
private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
private readonly configDir?: string;
private readonly rewindSdk: ClaudeRewindSdk;
constructor(options: ClaudeAgentClientOptions) {
this.defaults = options.defaults;
@@ -1511,6 +1519,7 @@ export class ClaudeAgentClient implements AgentClient {
options.resolveVersion ??
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
this.configDir = options.configDir;
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
}
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
@@ -1532,6 +1541,7 @@ export class ClaudeAgentClient implements AgentClient {
logger: this.logger,
queryFactory: this.queryFactory,
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
}
@@ -1560,6 +1570,7 @@ export class ClaudeAgentClient implements AgentClient {
logger: this.logger,
queryFactory: this.queryFactory,
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
}
@@ -2105,6 +2116,7 @@ class ClaudeAgentSession implements AgentSession {
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
private readonly rewindTurnAnchors: ClaudeRewindTurnAnchor[] = [];
private readonly rewindSdk: ClaudeRewindSdk;
private pendingFreshSessionId: string | null = null;
private recentStderr = "";
private closed = false;
@@ -2120,6 +2132,7 @@ class ClaudeAgentSession implements AgentSession {
this.logger = options.logger.child({ agentId: this.agentId });
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
this.contextUsage = new ClaudeContextUsageState(
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
);
@@ -2760,7 +2773,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
await revertClaudeConversation({
sdk: realClaudeRewindSdk,
sdk: this.rewindSdk,
sessionId: this.claudeSessionId,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
@@ -3041,6 +3054,13 @@ class ClaudeAgentSession implements AgentSession {
if (!messageId) {
return;
}
// Subagent frames ride the same stream as the conversation, but their uuids live on the
// subagent's sidechain, so forkSession cannot resolve one. Anchoring a turn on one leaves
// rewind throwing "Message <uuid> not found in session". The persisted history path already
// skips sidechain entries; the live stream has to skip them too.
if (readClaudeParentToolUseId(message)) {
return;
}
if (
message.type === "user" &&
!isSyntheticUserEntry(message) &&
@@ -3076,17 +3096,18 @@ class ClaudeAgentSession implements AgentSession {
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
}
if (index === 0) {
return { kind: "fresh-session" };
// A turn the model never answered — an instant gateway error, or an abort before the first
// token — leaves its anchor without an assistant id, and carries no conversation state worth
// preserving. Fork at the most recent turn before the target that did answer. Reading only
// the immediately preceding anchor let one such turn fail every later rewind in the
// conversation.
for (let previous = index - 1; previous >= 0; previous -= 1) {
const assistantMessageId = this.rewindTurnAnchors[previous]?.assistantMessageId;
if (assistantMessageId) {
return { kind: "fork", messageId: assistantMessageId };
}
}
const previousTurn = this.rewindTurnAnchors[index - 1];
if (!previousTurn?.assistantMessageId) {
throw new Error(
`Claude rewind cannot preserve turn ${index} because its assistant response id was not observed`,
);
}
return { kind: "fork", messageId: previousTurn.assistantMessageId };
return { kind: "fresh-session" };
}
private async ensureQuery(): Promise<Query> {
@@ -3374,9 +3395,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 +3427,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"],
@@ -201,6 +210,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
env: {
ANTHROPIC_MODEL: "openrouter/anthropic/claude-sonnet-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ollama/qwen3-coder",
ANTHROPIC_DEFAULT_FABLE_MODEL: "claude-fable-5-1[1M]",
ANTHROPIC_DEFAULT_OPUS_MODEL: "bedrock-opus-from-env",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
@@ -235,6 +245,12 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
label: "ollama/qwen3-coder",
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
},
{
provider: "claude",
id: "claude-fable-5-1[1M]",
label: "claude-fable-5-1[1M]",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
},
{
provider: "claude",
id: "bedrock-opus-from-env",
@@ -254,6 +270,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
},
]);
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
});
it("falls back to hardcoded models when settings.json is missing", async () => {
@@ -417,6 +435,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 +469,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 +546,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 +589,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);
@@ -14,6 +14,7 @@ import {
const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [
"ANTHROPIC_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
@@ -48,6 +48,7 @@ export const ClaudeProviderOptionsSchema = z
allowedTools: z.array(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
additionalDirectories: z.array(z.string()).optional(),
extraArgs: z.record(z.string(), z.string().nullable()).optional(),
sandbox: z
.object({
enabled: z.boolean().optional(),
@@ -99,6 +99,8 @@ async function createConnectedSession(
describe("Codex app-server provider features", () => {
test.each([
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.6-terra",

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