Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 4f5224122a fix(codex): invoke an inline skill steered into a running turn 2026-09-26 19:54:47 +02:00
paseo-bot[bot] af27e28054 fix(codex): invoke a skill picked from autocomplete after prose
Composer autocomplete inserts a Codex skill as /name anywhere in the
prompt, but only a prompt starting with /name was sent to Codex as a
skill input item. A skill placed after other text reached Codex as plain
text and was never injected.

Resolve /name mentions of enabled skills in the prompt into skill input
items and $name text, the same shape the leading form already sends.

Closes #5008
2026-09-26 17:31:15 +02:00
c906c2f4a4 fix(opencode): support v2 with automatic version selection (#5198)
* fix(opencode): support v2 while preserving v1 provider behavior

* fix(opencode): target v2.0.7, keeping v1 support

OpenCode 2.0.4 removed the /api/health endpoint and the plugin
await-activation endpoint, and moved form reply/cancel under the session
namespace. The v2 adapter was pinned to the 2.0.3 SDK, so any 2.0.4+
binary failed at helper-server startup with an opaque HTTP 404.

- Pin @opencode/client to 2.0.7 and require v2 >= 2.0.4 at runtime
  selection, with a clear error for older binaries.
- Use server.info for helper-server readiness.
- Move form reply/cancel/list to session.form and send the permission
  decision field instead of reply.
- Replace plugin.awaitActivation with a plugin.list readiness wait. A cold
  location registers its config-derived commands, skills, and providers
  asynchronously; an empty inventory means it is still warming.
- Send session.command.name, which replaced command, and drop the removed
  SkillInfo.slash field.
- Update the v2 test harness, unit tests, dependency lockfile, CI matrix,
  and provider docs to 2.0.7.

* fix(opencode): target 2.0.10 and address v2 review findings

* test(opencode): colocate v2 suites with their modules

Greptile's review flagged that the v2 tests lived in top-level
opencode-agent.*.test.ts files and imported the readiness helper, v2
client, harness, and API types from a distance. Move each v2 suite beside
the module it describes and leave the top-level files to the v1 public
path:

- opencode/v2/agent.test.ts: session lifecycle, structured output,
  resume, reconciliation, interrupt/replacement, helper isolation.
- opencode/v2/readiness.test.ts: location readiness deadline behavior.
- opencode/v2/permissions.test.ts: question and approval routing.
- opencode/v2/configuration.test.ts: permissionRules, feature flags, and
  helper-isolation decisions, exercised directly through the module.
- opencode/v2/mapping.test.ts: model and mode normalization.
- opencode/runtime-client.test.ts: runtime version selection.

The v1 recovery, permission-action, and catalog-deadline suites stay in
place. Test counts are preserved, with deeper per-module coverage for
configuration.

* test(opencode): split v2 local E2E by behavior and add harness helpers

The re-review flagged the v2 scenarios in opencode-bridge.local.e2e.test.ts
for inline event filtering, permission-kind branching, and polling in test
bodies. Move stream, permission, and subagent coordination behind helpers
in opencode/test-utils/v2-local-e2e-helpers.ts, and split the session
scenarios into opencode-v2.local.e2e.test.ts so each file covers one
behavior:

- opencode-bridge.local.e2e.test.ts: process sharing, shell.env scoping,
  the native tool bridge, and runtime version selection.
- opencode-v2.local.e2e.test.ts: session lifecycle, structured output,
  streaming, approvals, MCP preapproval, stop/replacement, rewind and
  subagents, commands, and skills/attachments.

Each helper waits on or answers the provider directly, so the tests state
domain intent and assert the collected result instead of polling or
branching inline. The CI-selected "versioned runtime" case stays in the
bridge file, and the live approvals and stop/replacement scenarios were
re-run green through the new helpers.

* fix(ci): preserve required server checks across OpenCode versions

Run v1 and v2 integration coverage sequentially so branch protection keeps its existing static check names.

* fix(agents): load startup history before registering sessions

A failed history stream while adding a runtime notice must close the unregistered session instead of leaving an initializing agent behind.

* fix(agents): limit startup history before buffering it

Apply the existing timeline content limits to each incoming history event so raw cumulative tool output is not retained until registration completes.

* fix(opencode): resolve Windows executables from platform packages

Recent v1 npm releases place opencode.exe in the architecture dependency rather than the CLI bin directory. Resolve that dependency from the CLI package while retaining support for the older layout.

* refactor(opencode): name public runtime option types

* test(opencode): let the spec own both runtime versions

Install each version in an isolated temporary directory and select it through provider configuration. Restore the CI workflow unchanged so version coverage is owned by the spec rather than workflow orchestration.

* test(opencode): launch native Windows binaries in isolated specs

Avoid npm command wrappers retaining locked executable files after helper shutdown during temporary installation cleanup.

* fix(opencode): preserve the legacy path when adding v2 support

Fall back to the existing client when version probing is inconclusive. Load v2 code and its bridge plugin only after positive v2 selection, keep v1 sessions undecorated, and restore original v1 discovery errors.

* fix(opencode): retry inconclusive version selection

A failed or unrecognized version probe previously cached the legacy fallback, pinning an upgraded binary to the v1 API until client recreation. Retry detection on the next operation while keeping the legacy path available.

* fix(opencode): release legacy runtime after inconclusive probe

Retryable version detection can clear the selected client after falling back to v1. Track whether the legacy client was selected so shutdown still releases its server without touching an unused v1 manager during a v2-only run.

* test(opencode): assert full runtime notice history

* fix(opencode): restore notice and retry behavior after rebase

---------

Co-authored-by: Jonathan karras <6307688+karrots@users.noreply.github.com>
Co-authored-by: Dhruv Singal <dhruvsingalabc@gmail.com>
2026-09-26 17:18:31 +02:00
paseo-bot[bot] fffd76e8fb List a custom Codex provider's sessions in Import session (#5446)
A provider that extends codex with its own OPENAI_BASE_URL starts its
threads under its own Codex model provider id. Codex's thread/list returns
only the model provider its config selects unless the request names
others, and the import scan named none, so those sessions were missing
from the import list. After switching the default provider in the Codex
config, stock Codex listed them instead and imported them onto itself.

Ask Codex for the custom provider's own threads when listing its
importable sessions. Stock Codex keeps its current listing.

Closes #5381

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 17:15:53 +02:00
Mohamed Boudra d2e2154a8a fix(app): keep restored workspaces during directory refresh (#5394)
* fix(app): keep restored workspaces during directory refresh

Capture workspace maps and cursors from one transaction baseline so cache hydration cannot pair an empty map with a current cursor. Reject legacy directory checkpoints to recover caches already persisted with missing workspaces.

* test(app): clarify workspace cache recovery flow
2026-09-26 16:58:53 +02:00
paseo-bot[bot] cb9654a65c fix(server): keep the daemon running when daemon.log cannot be written (#5445)
A failed write to daemon.log (for example ENOSPC on a full disk) made the
supervisor's rotating log stream emit an unhandled 'error' event, which
killed the supervisor; the worker then exited on lost supervisor liveness.

The supervisor now treats daemon.log as best effort: a failed stream is
reported once on stderr, dropped, and reopened on the next log line, so
logging resumes once the file is writable again.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 15:56:42 +02:00
paseo-bot[bot]andqinkangdeid 3dc17b7f30 fix(acp): keep the daemon running when an ACP agent cannot start (#5439)
Opening an archived ACP agent whose worktree was removed spawned the
provider in the missing directory. The session spawn had no listener for
the child's error event, so Node's ENOENT became an uncaught exception
and the daemon restarted, then crashed again when the client retried.

The session spawn now rejects initialization on a spawn error, the same
way the provider probe already did, and both share one helper.

Closes #5435

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: qinkangdeid <qinkangdeidd@gmail.com>
2026-09-26 15:10:44 +02:00
paseo-bot[bot] 76a9781ba5 Read Claude history from the provider's own config directory (#5437)
A provider that extends claude and sets CLAUDE_CONFIG_DIR in its env
writes transcripts and settings.json under that directory, but the
daemon looked them up under its own CLAUDE_CONFIG_DIR or ~/.claude.
Agents on that provider reopened with an empty timeline after a daemon
restart, their sessions were missing from the import list, and models
from its settings.json were missing from the catalog.

Resolve the config directory from the same environment the provider is
launched with, and drop the test-only configDir client option.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 13:57:27 +02:00
paseo-bot[bot] a272a22d7e fix(opencode): hide user messages OpenCode marks synthetic (#5434)
OpenCode adds text to user messages on its own behalf (continue prompts after
compaction or a subtask, MCP resource contents, plugin prompts) and flags that
text `synthetic: true`. Its own UI hides it. Paseo built user bubbles from every
text part, so reopening an OpenCode chat showed those messages as if the user
had typed them, and appended synthetic text to the user's own message.

Closes #5425

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 13:24:18 +02:00
paseo-bot[bot] 26f227bed9 fix(pi): keep a chat rewind after the daemon restarts (#5432)
Pi's navigateTree without a summary only moves the session's leaf in
memory and appends nothing to the session file. Pi reopens a session at
the file's last entry, so after a daemon restart the resumed chat showed
the messages the rewind had removed.

After navigating, the Paseo extension now appends a paseo_rewind custom
entry on the new branch. Custom entries carry no model context, and the
file's last entry now sits on the rewound branch, so Pi reopens there.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 11:58:43 +02:00
paseo-bot[bot] aa3ffaeb6c Refresh project skills in the / menu after the checkout's branch changes (#5415)
Draft slash commands are cached per provider and checkout with no expiry, so a skill from the previous branch stayed listed (or a new branch's skill stayed hidden) until the app restarted. A pushed checkout status that reports a different branch now drops that checkout's cached draft commands, and the next / menu fetches them again.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 11:06:22 +02:00
paseo-bot[bot] 513f2a9ea2 List a generic ACP agent's slash commands in a new agent's / menu (#5411)
* List a generic ACP agent's slash commands in a new agent's / menu

Generic ACP agents advertise slash commands with available_commands_update
after session/new. The new-agent / menu opens a short-lived session and asks
for its commands right after session/new, so it always got an empty list,
which the app keeps for the draft. Wait for the first batch, bounded by the
existing initial-commands timeout, as the Cursor, Kiro, and TRAE clients do.

Closes #4759

* Keep the generic ACP command test independent of scheduling

* Wait for agent state writes before removing MCP test temp dirs

The blocking send_agent_prompt timeout test removed its temp dir while
AgentManager background tasks and AgentStorage writes were still in
flight, which intermittently failed cleanup with ENOTEMPTY on Linux CI.
Flush both before removing the dir in the tests that use real storage.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 10:19:39 +02:00
paseo-bot[bot] 8cd989529e Notify a caller once when it prompts a child that is still running (#5407)
* Notify a caller once when it prompts a child that is still running

create_agent and a background send_agent_prompt each armed their own
finish notification for the same child and caller. When the second
prompt replaces the child's running turn, the child never goes idle in
between, so its one finish fired both and the caller got the same
notification twice. Arming again for a child and caller now replaces
the earlier notification.

Closes #5397

* Declare the notification test helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 17:02:10 +02:00
paseo-bot[bot] 04c3e003f8 Keep model picker rows free of nested buttons on web (#5404)
* Keep the model row's profile action out of the row button on web

On web the model row renders as a <button>, and the create/edit profile
action inside it rendered a second <button>, so React logged "<button>
cannot be a descendant of <button>" for every row. The action now sits
beside the row button inside the same hover boundary, positioned over a
slot the row reserves, so layout and hover reveal are unchanged.

Closes #5385

* Check the create-profile action leaves the composer's model unchanged

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 16:19:28 +02:00
paseo-bot[bot] e68553f759 Run agents on another daemon from inside an agent session (#5392)
* Run agents on another daemon from inside an agent session

paseo run sent PASEO_AGENT_ID as the caller to whichever daemon --host or
--home pointed at. That id names an agent on the daemon that launched the
shell, so another daemon rejected the run with "Caller agent <id> not
found". The run now claims the caller only when the target daemon has
that agent, and otherwise runs as a top-level agent there.

Closes #5313

* Keep the caller when the agent lookup loses its connection

Only the daemon's answer that it has no such agent drops the caller. A
lost or timed-out connection now fails the run instead of creating a
top-level agent on the caller's own daemon.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 14:50:04 +02:00
paseo-bot[bot]andliujin0506 dc9799f6fc Open OSC 8 terminal links the same way as plain URLs (#5388)
* Open OSC 8 terminal hyperlinks like plain URLs

Clicking a link a CLI printed with OSC 8 (gh, npm, ls --hyperlink)
showed xterm's "Do you want to navigate to" prompt and then opened a
bare Paseo window, while the same URL printed as plain text opened in
the system browser. The terminal runtime now gives xterm a link handler
that sends OSC 8 links to the same external opener as plain URLs.

Closes #4768

Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>

* Name the hyperlink and line writes in the terminal link test

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>
2026-09-25 13:54:49 +02:00
paseo-bot[bot] 43a2a7969c Rewind a Pi chat to the message you pick after an earlier rewind (#5383)
* Rewind a Pi chat to the message you pick after an earlier rewind

After a rewind, Pi's session file keeps the abandoned branch. Paseo
captured user entry ids from the whole file but replayed history from
the current branch, pairing them by position. Each abandoned user
message shifted later rows onto an earlier entry id, so rewinding a
replayed row went back further than the selected message.

Capture user entries from the same compaction-aware branch Pi replays,
so the nth capture is the nth replayed user message.

* Assert Pi entry captures as data with named test helpers

* Name the fields of Pi user entry test fixtures

* Rewind a Pi row whose entry compaction summarized away

The capture fed both replay pairing and the rewind target lookup. Narrowing
it to the context branch broke rewinding a live row from before /compact,
because that row's entry is off the branch. The extension now reports the
session's user entries for the lookup apart from the branch entries replay
pairs with.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 13:16:40 +02:00
Joe Shull 90d978ab68 fix(omp): preserve described select options (#3628)
* fix(omp): preserve described select options

* test(omp): assert exact option arrays instead of toMatchObject

toMatchObject ignores extra/missing keys, so the described-select and
malformed-metadata tests passed even with option-index misalignment or
an unfiltered whitespace-only description. Switched to toStrictEqual
on the options array so both are actually caught.
2026-09-25 10:25:19 +02:00
paseo-bot[bot] a048094da4 Scroll the theme list when plugin themes overflow the window (#5374)
* fix(app): scroll the theme list when plugin themes overflow the window

The theme menu lists every plugin-contributed theme but did not opt into
the menu surface's scrollable mode, so its height was never capped to the
window. A theme pack with dozens of entries pushed the lower themes off
screen with no way to reach them.

* test(app): scroll the theme menu through a named helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 09:14:24 +02:00
paseo-bot[bot] 315803688d Update and restart a daemon whose provider checks take over 1.5s (#5372)
The host settings Update and Restart buttons read the worker's status
before sending the request, with a 1500 ms timeout. The daemon answers
status only after probing each provider CLI with --version, which may
take up to 2 s per provider. On a host where one provider CLI starts
slowly, the pre-check always timed out and the request was never sent.

Read the status with the client's default request timeout, the same
full status read the supervisor restart test already exercises.

Closes #5367

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 08:10:09 +02:00
paseo-bot[bot] bf4dc22c2f Explain that daemon set-password needs a terminal when stdin is piped (#5358)
* Explain that daemon set-password needs a terminal when stdin is piped

The password prompt only resolves on a keypress from a terminal. With
stdin piped or redirected it never settled, so Node exited 13 with an
unsettled top-level await warning and no message. The command now fails
with PASSWORD_TTY_REQUIRED and points at PASEO_PASSWORD.

Closes #5350

* Assert the set-password terminal error message

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 06:54:28 +02:00
paseo-bot[bot] 5c9f767d51 Notify the caller when a blocking send_agent_prompt outlasts its 30s wait (#5347)
* fix(server): notify the caller when a blocking send_agent_prompt outlasts its wait

An agent-scoped send_agent_prompt with background: false only armed the
finish notification for background sends. When the 30s wait timed out,
the tool returned "still running" and nothing was armed, so the child's
result never reached the caller. The wait now reports whether it timed
out, and a timed-out wait on a still-running agent arms the same
notification a background send gets and says so in guidance. A blocking
send that finishes inside the wait still returns its result without a
notification.

* refactor(server): declare the finish-notification helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 05:00:12 +02:00
paseo-bot[bot] 84304b5532 Start model-less Pi agents on Pi's configured default model (#5343)
A Pi agent created without a model took the first entry of Pi's model
list, which ignores defaultProvider/defaultModel in Pi's settings. The Pi
catalog now marks the model a fresh Pi session reports in get_state as
the default, so the daemon's default-model fallback picks it.

Closes #4918

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 01:59:26 +02:00
paseo-bot[bot] 28507224dc Show the date for a message from today's weekday last week (#5341)
A reply sent last Friday at 12:02 PM, read on Friday at 11:47 AM, was
labelled "Friday 12:02 PM" because the weekday window counted 24-hour
periods, and that reply was just under seven of them old. Count calendar
days instead, so the weekday form covers only the previous six days and
today's weekday a week back shows its date.

Closes #5225

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 00:54:41 +02:00
paseo-bot[bot] db4fd33406 Reconnect OpenCode agents to the new server after the old one exits (#5338)
* Reconnect OpenCode agents to the new server after the old one exits

When the OpenCode server process exits, Paseo starts the next one on a
new port, but existing sessions kept their client bound to the old port
and failed every turn with ECONNREFUSED until the agent was reloaded.

A session now marks its connection dead when the server exits and
connects to the current server before its next turn, rewind, history
read, mode or command lookup.

Closes #5307

* Settle the exited OpenCode server's events before reconnecting

A queued exit from the old server could otherwise fail a turn started on
the new one.

* Re-add a reconnected OpenCode session's MCP servers on the new server

reconnect() reset mcpConfigured but left mcpSetupPromise holding the
first server's resolved setup, so the next turn awaited it and never
called mcp.add on the new server. Track MCP setup with one field that
reconnect clears.

* Re-run CI after an unrelated macOS file-observation flake

* Re-run CI after an unrelated website WebKit timeout

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 00:02:53 +02:00
Mohamed Boudra e3c853df58 fix(android): avoid lint memory pressure in store builds 2026-09-24 12:46:57 +02:00
paseo-ai[bot] ea7a741851 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-24 10:20:03 +00:00
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
246 changed files with 17421 additions and 1575 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
+60
View File
@@ -1,5 +1,65 @@
# 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
+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.
+21 -8
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).
@@ -60,7 +69,7 @@ model; it does not override the model list returned by a resolver.
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -86,9 +95,13 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
@@ -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-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
sha256-UXnB6q5tubKpTs+A5+u/NLSzc8ZK6rAsQs+kEphEKd8=
+266 -51
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.9.1",
"version": "0.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.1",
"version": "0.9.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -42,6 +42,13 @@
"ws": "^8.20.0"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
"dev": true,
"license": "MIT"
},
"node_modules/@0no-co/graphql.web": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
@@ -8271,6 +8278,84 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -8644,6 +8729,48 @@
"node": ">=20.0"
}
},
"node_modules/@opencode/client": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
"license": "MIT",
"dependencies": {
"@opencode/protocol": "2.0.10",
"@opencode/schema": "2.0.10"
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
},
"solid-js": {
"optional": true
}
}
},
"node_modules/@opencode/protocol": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
"license": "MIT",
"dependencies": {
"@opencode/schema": "2.0.10",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opencode/schema": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.1.0",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -14263,13 +14390,6 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
"dev": true,
"license": "MIT"
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -18622,6 +18742,16 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/effect": {
"version": "4.0.0-rc.112",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
"license": "MIT",
"dependencies": {
"fast-check": "^4.9.0",
"msgpackr": "^2.0.5"
}
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -21866,6 +21996,28 @@
"node": ">=18"
}
},
"node_modules/fast-check": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
@@ -28141,6 +28293,37 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
@@ -28466,6 +28649,21 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-gyp/node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -30141,6 +30339,22 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -33127,23 +33341,6 @@
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/string-length": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
@@ -33318,6 +33515,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -36196,7 +36410,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.1",
"version": "0.9.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37262,12 +37476,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.9.1",
"version": "0.9.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"@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 +37728,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.9.1",
"version": "0.9.2",
"dependencies": {
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37528,7 +37742,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.1",
"version": "0.9.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37772,7 +37986,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.1",
"version": "0.9.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38668,7 +38882,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.1",
"version": "0.9.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38697,14 +38911,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.9.1",
"version": "0.9.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@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 +38928,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@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 +38951,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.9.1",
"version": "0.9.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38764,7 +38978,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.9.1",
"version": "0.9.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38982,19 +39196,20 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.9.1",
"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.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@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",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -39895,7 +40110,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.9.1",
"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.1",
"version": "0.9.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -6,13 +6,16 @@ import {
expectAgentProfilesEmptyPrompt,
expectProfileEditTooltip,
expectComposerMode,
expectCreateProfileFromModelRow,
expectComposerModel,
expectModelRowProfileActionBesideRow,
expectModelRowSelected,
expectProfileEditIsPencilOnly,
expectProfileVisibleForProvider,
openModelPicker,
openAgentProfilesFromEmptyPrompt,
seedAgentProfiles,
selectModelRow,
} from "../support/helpers/agent-profiles";
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -115,4 +118,34 @@ test.describe("Agent profiles in the model picker", () => {
await seed.restore();
}
});
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
const seed = await seedAgentProfiles([]);
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-profiles-row-action-",
title: "Agent profiles row action",
model: "ten-second-stream",
modeId: "load-test",
});
try {
const oneMinute = {
provider: "mock",
modelId: "one-minute-stream",
modelLabel: "One minute stream",
};
await openAgentRoute(page, workspace);
await expectComposerVisible(page);
await openModelPicker(page);
await expectModelRowProfileActionBesideRow(page, oneMinute);
await expectCreateProfileFromModelRow(page, oneMinute);
await expectComposerModel(page, "Ten second stream");
await openModelPicker(page);
await selectModelRow(page, oneMinute);
await expectComposerModel(page, oneMinute.modelLabel);
} finally {
await workspace.cleanup();
await seed.restore();
}
});
});
@@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
@@ -25,3 +28,39 @@ test("settings restart waits for a replacement worker while retaining the superv
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page,
}) => {
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
const daemon = await startIsolatedHostDaemon(randomUUID(), {
paseoHome: await createHomeWithSlowProvider(1_800),
});
try {
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
} finally {
await daemon.close();
}
});
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
const provider = path.join(paseoHome, "slow-provider");
await writeFile(
provider,
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
{ mode: 0o700 },
);
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
version: 1,
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
})}\n`,
);
return paseoHome;
}
@@ -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);
});
});
@@ -2,6 +2,7 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { 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 { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
@@ -120,3 +121,75 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await rm(directory, { recursive: true, force: true });
}
});
const MANY_THEMES_PLUGIN_ID = "plugin-theme-many-e2e";
const MANY_THEMES_COUNT = 40;
const LAST_THEME_NAME = `Pack theme ${MANY_THEMES_COUNT}`;
const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
for (let index = 1; index <= ${MANY_THEMES_COUNT}; index += 1) {
plugin.addTheme({
id: "pack-" + index,
name: "Pack theme " + index,
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
});
}
return () => {};
}`;
async function scrollThemeMenuToLastTheme(page: Page) {
const firstTheme = page.getByText("Pack theme 1", { exact: true });
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
await firstTheme.hover();
await page.mouse.wheel(0, 4000);
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
}
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: MANY_THEMES_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.ts"), MANY_THEMES_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/settings");
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
await page.getByLabel("Theme: System", { exact: true }).click();
await scrollThemeMenuToLastTheme(page);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
animations: "disabled",
});
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
} finally {
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -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,4 +1,5 @@
import path from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
@@ -11,6 +12,7 @@ import {
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { escapeRegex } from "../support/helpers/regex";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
@@ -108,7 +110,207 @@ async function withPaseoOwnedWorktree(
}
}
async function readCachedRowIds(
page: Page,
rowKind: "workspace" | "checkpoint",
): Promise<string[]> {
return page.evaluate(
async ({ serverId, kind }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("paseo-replica-row-store");
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
try {
return await new Promise<string[]>((resolve, reject) => {
const transaction = database.transaction("rows", "readonly");
const request = transaction.objectStore("rows").getAll();
request.onsuccess = () =>
resolve(
request.result
.filter(
(row: { serverId: string; kind: string }) =>
row.serverId === serverId && row.kind === kind,
)
.map((row: { id: string }) => row.id)
.sort(),
);
request.addEventListener("error", () => reject(request.error));
});
} finally {
database.close();
}
},
{ serverId: getServerId(), kind: rowKind },
);
}
async function simulateDamagedLegacyDirectoryCache(
page: Page,
workspaceIdToRemove: string,
): Promise<void> {
// Stop the app before altering storage so no pending app write can repair the fixture.
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
await page.route(fixtureUrl, (route) =>
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
);
await page.goto(fixtureUrl);
await page.evaluate(
async ({ serverId, missingWorkspaceId }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("paseo-replica-row-store");
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
try {
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction("rows", "readwrite");
const store = transaction.objectStore("rows");
const request = store.getAll();
request.onsuccess = () => {
const rows = request.result as Array<{
serverId: string;
kind: string;
id: string;
payload: string;
}>;
const checkpoint = rows.find(
(row) => row.serverId === serverId && row.kind === "checkpoint",
);
if (!checkpoint) {
transaction.abort();
return;
}
const stored = JSON.parse(checkpoint.payload);
// Accept either writer so this fixture also reproduces on the unfixed client.
const cursors = stored.version === 1 ? stored.cursors : stored;
if (!cursors.workspaces?.generation) {
transaction.abort();
return;
}
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
for (const row of rows) {
if (
row.serverId === serverId &&
row.kind === "workspace" &&
row.id === missingWorkspaceId
)
store.delete([serverId, row.kind, row.id]);
}
};
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
);
transaction.addEventListener("error", () => reject(transaction.error));
});
} finally {
database.close();
}
},
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
);
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
}
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
const server = socket.connectToServer();
server.onMessage(async (message) => {
await ready;
socket.send(message);
});
});
return release;
}
async function createPinnedSiblingWorkspace(
project: Awaited<ReturnType<typeof seedWorkspace>>,
): Promise<string> {
const sibling = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
title: "Pinned sibling",
});
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
await project.client.setWorkspacePinned(sibling.workspace.id, true);
return sibling.workspace.id;
}
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
for (const workspaceId of workspaceIds) {
await waitForSidebarWorkspace(page, workspaceId);
}
}
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
await gotoAppShell(page);
await expectSidebarWorkspaces(page, workspaceIds);
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
}
async function reopenDamagedCacheBeforeHostResponds(
page: Page,
missingWorkspaceId: string,
): Promise<() => void> {
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
await gotoAppShell(page);
return releaseHost;
}
async function expectDamagedCacheHydrated(
page: Page,
retainedWorkspaceId: string,
missingWorkspaceId: string,
): Promise<void> {
await waitForSidebarWorkspace(page, retainedWorkspaceId);
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
}
async function expectRecoveredWorkspacesAfterReload(
page: Page,
workspaceIds: string[],
testInfo: TestInfo,
): Promise<void> {
await expectSidebarWorkspaces(page, workspaceIds);
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
await page.reload();
await expectSidebarWorkspaces(page, workspaceIds);
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
await testInfo.attach("recovered-workspaces", {
path: testInfo.outputPath("recovered-workspaces.png"),
contentType: "image/png",
});
}
test.describe("Sidebar workspace list", () => {
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
page,
}, testInfo) => {
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
try {
const siblingId = await createPinnedSiblingWorkspace(project);
const ids = [project.workspaceId, siblingId].sort();
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
await test.step("keep cached workspace and discard stale checkpoint", () =>
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
releaseHost();
await test.step("recover missing workspace and retain both after reload", () =>
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
} finally {
await project.cleanup();
}
});
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-remote-",
@@ -482,6 +482,50 @@ export async function expectModelRowSelected(
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
}
function modelRow(page: Page, input: { provider: string; modelId: string }): Locator {
return pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`);
}
/**
* A model row's profile action is its own button. On web, a button inside the
* row's button is invalid HTML that React reports on every render.
*/
export async function expectModelRowProfileActionBesideRow(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
const row = modelRow(page, input);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(pickerViewport(page).locator("button button")).toHaveCount(0);
await expect(row.getByRole("button")).toHaveCount(0);
}
/** The hover-revealed plus on an unprofiled model row opens a seeded profile form. */
export async function expectCreateProfileFromModelRow(
page: Page,
input: { provider: string; modelId: string; modelLabel: string },
): Promise<void> {
await modelRow(page, input).hover();
await pickerViewport(page)
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
input.modelLabel,
);
await expect(modal.getByRole("button", { name: `Model (${input.modelLabel})` })).toBeVisible();
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(modal).toHaveCount(0, { timeout: 30_000 });
}
export async function selectModelRow(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
await modelRow(page, input).click();
}
// ─── Cross-provider model search ───────────────────────────────────────────
export async function searchAllModels(page: Page, query: string): Promise<void> {
+2 -1
View File
@@ -19,7 +19,8 @@
"APP_VARIANT": "production"
},
"android": {
"resourceClass": "large"
"resourceClass": "large",
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
}
},
"production-apk": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.1",
"version": "0.9.2",
"private": true,
"main": "index.ts",
"scripts": {
+17 -11
View File
@@ -625,13 +625,6 @@ function ModelRowProfileAction({
}) {
const isCompact = useIsCompactFormFactor();
const visible = hovered || isNative || isCompact;
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onPress();
},
[onPress],
);
const pressableStyle = useCallback(
({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.rowIconButton,
@@ -645,7 +638,7 @@ function ModelRowProfileAction({
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={handlePress}
onPress={onPress}
hitSlop={8}
style={pressableStyle}
pointerEvents={visible ? "auto" : "none"}
@@ -778,7 +771,6 @@ function ModelRow({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.browserRow,
styles.browserModelRow,
Boolean(hovered) && styles.browserRowHovered,
pressed && styles.browserRowPressed,
],
@@ -787,7 +779,7 @@ function ModelRow({
return (
<View
style={styles.modelRowHoverBoundary}
style={[styles.modelRowHoverBoundary, styles.browserModelRow]}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
@@ -815,10 +807,17 @@ function ModelRow({
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
) : null}
</View>
{profileAction}
{profileAction ? <View style={styles.rowIconButton} /> : null}
</View>
</View>
</ModelBrowserPressable>
{/* The row renders a <button> on web, so its profile action sits beside it,
over the slot reserved above, rather than inside it. */}
{profileAction ? (
<View style={styles.modelRowProfileActionSlot} pointerEvents="box-none">
{profileAction}
</View>
) : null}
</View>
);
}
@@ -1590,6 +1589,13 @@ const styles = StyleSheet.create((theme) => ({
modelRowHoverBoundary: {
position: "relative",
},
modelRowProfileActionSlot: {
position: "absolute",
top: 0,
bottom: 0,
right: isWeb ? theme.spacing[3] : theme.spacing[6],
justifyContent: "center",
},
browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] },
browserRowHovered: {
backgroundColor: theme.colors.surface1,
@@ -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;
},
};
@@ -16,6 +16,7 @@ import {
resolveWorkingDiffComparison,
selectWorkingDiffComparison,
} from "@/git/working-diff-comparison";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import {
applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus,
@@ -197,6 +198,51 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
).toBe(false);
});
it("drops the checkout's cached draft slash commands when its branch changes", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutStatusQueryKey(serverId, cwd),
checkoutStatus({ currentBranch: "chore/build-paseo" }),
);
const thisCheckout = draftAgentCommandsQueryKey({
serverId,
draftConfig: { provider: "claude", cwd, model: "haiku" },
});
const otherCheckout = draftAgentCommandsQueryKey({
serverId,
draftConfig: { provider: "claude", cwd: "/repo2", model: "haiku" },
});
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
queryClient.setQueryData(otherCheckout, [{ name: "build-paseo" }]);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ currentBranch: "main" })),
});
expect(queryClient.getQueryData(thisCheckout)).toBeUndefined();
expect(queryClient.getQueryData(otherCheckout)).toEqual([{ name: "build-paseo" }]);
});
it("keeps the checkout's draft slash commands when a push leaves its branch unchanged", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), checkoutStatus());
const thisCheckout = draftAgentCommandsQueryKey({
serverId,
draftConfig: { provider: "claude", cwd, model: "haiku" },
});
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(queryClient.getQueryData(thisCheckout)).toEqual([{ name: "build-paseo" }]);
});
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
const queryClient = createQueryClient();
const pushedPr = prStatus({ requestId: "pr-1" });
@@ -8,6 +8,7 @@ import {
invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
@@ -67,7 +68,13 @@ export function applyCheckoutStatusUpdateFromEvent({
? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined;
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
const previousStatus = queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, payload.cwd),
);
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
if (previousStatus?.currentBranch !== payload.currentBranch) {
void resetDraftAgentCommandsForCheckout(queryClient, { serverId, cwd: payload.cwd });
}
void queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
});
@@ -38,9 +38,10 @@ describe("agent command query keys", () => {
"agentCommands",
"server-1",
"draft",
"codex",
"cwd",
"/repo",
"provider",
"codex",
"mode",
null,
"model",
+22 -4
View File
@@ -1,3 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
@@ -24,17 +25,24 @@ export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId:
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
}
function draftAgentCommandsCheckoutScope(input: { serverId: string; cwd: string }) {
return [
...agentCommandsQueryRoot(input.serverId),
"draft",
"cwd",
normalizeAgentCommandsCwd(input.cwd),
] as const;
}
export function draftAgentCommandsQueryKey(input: {
serverId: string;
draftConfig: AgentCommandsDraftConfig;
}) {
const { draftConfig } = input;
return [
...agentCommandsQueryRoot(input.serverId),
"draft",
...draftAgentCommandsCheckoutScope({ serverId: input.serverId, cwd: draftConfig.cwd }),
"provider",
draftConfig.provider,
"cwd",
normalizeAgentCommandsCwd(draftConfig.cwd),
"mode",
draftConfig.modeId ?? null,
"model",
@@ -46,6 +54,16 @@ export function draftAgentCommandsQueryKey(input: {
] as const;
}
// Draft commands include project skills read from the checkout. When the checkout moves to
// another branch the cached list describes files that are gone, so it is dropped rather than
// shown while a refetch runs.
export function resetDraftAgentCommandsForCheckout(
queryClient: QueryClient,
input: { serverId: string; cwd: string },
): Promise<void> {
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
}
export function agentCommandsQueryKey(input: {
serverId: string;
agentId: string;
+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;
}
@@ -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;
@@ -38,6 +44,7 @@ class FakeDirectoryClient {
projectResult: ProjectListResult | null = null;
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
private readonly handlers = new Map<
SessionOutboundMessage["type"],
Set<(message: SessionOutboundMessage) => void>
@@ -76,6 +83,14 @@ class FakeDirectoryClient {
return complete;
}
holdProjectFetch(): (result: ProjectListResult) => void {
let complete!: (result: ProjectListResult) => void;
this.pendingProjectFetch = new Promise((resolve) => {
complete = resolve;
});
return complete;
}
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
this.fetchAgentsCalls += 1;
this.lastAgentOptions = options;
@@ -110,6 +125,11 @@ class FakeDirectoryClient {
async listProjects(options?: unknown): Promise<ProjectListResult> {
this.listProjectsCalls += 1;
this.lastProjectOptions = options;
if (this.pendingProjectFetch) {
const pending = this.pendingProjectFetch;
this.pendingProjectFetch = null;
return pending;
}
if (this.projectResult) return this.projectResult;
return {
requestId: "projects",
@@ -144,19 +164,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,
@@ -204,9 +241,29 @@ function createAgent(serverId: string, id: string) {
};
}
function createWorkspaceEntry(id: string): WorkspaceFetchResult["entries"][number] {
return {
id,
projectId: "snapshot-project",
projectDisplayName: "Snapshot project",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
afterEach(() => {
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
serverIds.clear();
useWorkspaceLabels.setState({ hosts: {} });
});
describe("DirectorySync session readiness", () => {
@@ -1091,6 +1148,41 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
const serverId = "superseded-workspace-baseline";
const { client, directory } = createDirectory(serverId);
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 },
});
directory.acceptWorkspaces([normalizeWorkspaceDescriptor(createWorkspaceEntry("retained"))]);
const release = client.holdWorkspaceFetch();
const refresh = directory.refreshWorkspaces();
const rejected = expect(refresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 2 },
});
release({
requestId: "old-empty-snapshot",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
});
await rejected;
expect([...useSessionStore.getState().sessions[serverId].workspaces.keys()]).toEqual([
"retained",
]);
directory.dispose();
});
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
const serverId = "session-wait-reconnect";
const { client, directory } = createDirectory(serverId);
@@ -1158,9 +1250,19 @@ describe("DirectorySync session readiness", () => {
},
},
});
client.emit({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" },
},
});
completeFetch({
requestId: "workspaces",
entries: [],
entries: [
createWorkspaceEntry("removed-workspace"),
createWorkspaceEntry("updated-workspace"),
],
emptyProjects: [
{
projectId: "snapshot-project",
@@ -1183,6 +1285,11 @@ describe("DirectorySync session readiness", () => {
expect(projects?.get("workspace-project")).toMatchObject({
projectDisplayName: "Project from workspace update",
});
expect(
[...useSessionStore.getState().sessions[serverId].workspaces.values()].map(
({ id, name }) => ({ id, name }),
),
).toEqual([{ id: "updated-workspace", name: "Live name" }]);
directory.dispose();
});
@@ -1254,6 +1361,8 @@ function createSqliteCache() {
return {
cache,
database,
storage,
reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }),
holdRead: (operation: () => Promise<void>) => {
beforeRead = operation;
},
@@ -1349,3 +1458,298 @@ 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();
});
});
it.each(["before", "during", "before-metadata", "damaged"] as const)(
"retains unchanged workspaces across route/full demand and restart (cache: %s)",
async (timing) => {
const cacheFirst = timing === "before";
const damaged = timing === "damaged";
const serverId = `cache-route-overlap-${timing}`;
serverIds.add(serverId);
const { cache, database, reopen, storage } = createSqliteCache();
cache.setHosts([serverId]);
const projectEntry: ProjectListResult["projects"][number] = {
projectId: "P",
projectDisplayName: "Project P",
projectRootPath: "/repo",
projectKind: "git",
};
const project = normalizeProjectDescriptor(projectEntry);
const workspaceEntries: WorkspaceFetchResult["entries"] = ["A", "B"].map((id) => ({
id,
projectId: "P",
projectDisplayName: "Project P",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
}));
const workspaces = workspaceEntries.map(normalizeWorkspaceDescriptor);
const cursor = { generation: "g", afterSeq: 7 };
cache.replaceDirectoryBaseline(serverId, {
agents: new Map(),
projects: new Map([[project.projectId, project]]),
workspaces: new Map(
(damaged ? [] : workspaces).map((workspace) => [workspace.id, workspace]),
),
checkpoint: { projects: cursor, workspaces: cursor, agents: cursor },
});
await cache.flush();
if (damaged) {
await storage.apply({
deletes: [],
upserts: [
{
serverId,
kind: "checkpoint",
id: "singleton",
payload: JSON.stringify({ projects: cursor, workspaces: cursor, agents: cursor }),
},
],
});
}
// The host keeps A/B unchanged. A current cursor legitimately receives no rows.
class UnchangedDirectoryClient extends FakeDirectoryClient {
readonly workspaceSyncRequests: unknown[] = [];
override async fetchWorkspaces(
options?: Parameters<DaemonClient["fetchWorkspaces"]>[0],
): Promise<WorkspaceFetchResult> {
this.fetchWorkspacesCalls += 1;
this.lastWorkspaceOptions = options;
this.workspaceSyncRequests.push(options?.sync);
const changes = options?.sync?.generation === "g" && options.sync.afterSeq === 7;
return {
requestId: "workspaces",
entries: changes ? [] : [workspaceEntries[options?.page?.cursor ? 1 : 0]],
emptyProjects: [],
pageInfo: {
hasMore: !changes && !options?.page?.cursor,
nextCursor: !changes && !options?.page?.cursor ? "page-2" : null,
prevCursor: null,
},
sync: {
generation: "g",
headSeq: 7,
mode: changes ? "changes" : "snapshot",
removals: [],
},
};
}
}
const client = new UnchangedDirectoryClient();
const releaseAgents = client.holdAgentFetch();
const releaseProjects = client.holdProjectFetch();
const projectResult: ProjectListResult = {
requestId: "projects",
projects: [projectEntry],
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
};
client.projectResult = projectResult;
const callbacks = {
onAgentStoppedRunning: () => {},
markAgentLoading: () => {},
markAgentReady: () => {},
markAgentError: () => {},
};
const directory = new DirectorySync(serverId, callbacks, cache);
const publishServerInfo = () => {
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true, projectList: true },
});
};
const initializeSession = (ready = true) => {
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
if (ready) publishServerInfo();
};
const connection = {
client: client as unknown as DaemonClient,
status: "online" as const,
source: { clientGeneration: 1, connectionEpoch: 1 },
};
initializeSession(timing !== "before-metadata");
directory.connectionChanged(connection);
const visibleIds = () =>
[...useSessionStore.getState().sessions[serverId].workspaces.keys()].sort();
let restarted: DirectorySync | undefined;
const restartedCache = reopen();
try {
if (cacheFirst) await directory.restoreCachedDirectory();
directory.setAgentRouteDemand(["agent"]);
if (timing === "before-metadata") {
await directory.restoreCachedDirectory();
publishServerInfo();
}
await expect.poll(() => client.listProjectsCalls).toBe(1);
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
directory.setDemand({}, true);
await directory.restoreCachedDirectory();
expect(visibleIds()).toEqual(damaged ? [] : ["A", "B"]);
releaseProjects(projectResult);
releaseAgents({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: {
generation: "g",
headSeq: 7,
mode: cacheFirst ? "changes" : "snapshot",
removals: [],
},
});
await directory.refreshDemand();
expect(client.lastProjectOptions).toEqual({ sync: cacheFirst ? cursor : {} });
expect(client.workspaceSyncRequests).toEqual(cacheFirst ? [cursor] : [{}, {}]);
expect.soft(visibleIds(), "after overlapping refresh").toEqual(["A", "B"]);
await cache.flush();
expect([...(await cache.readDirectory(serverId)).workspaces.keys()].sort()).toEqual([
"A",
"B",
]);
await directory.refreshAll();
await cache.flush();
directory.dispose();
useSessionStore.getState().clearSession(serverId);
restartedCache.setHosts([serverId]);
const persisted = await restartedCache.readDirectory(serverId);
expect(persisted.checkpoint?.workspaces).toEqual(cursor);
expect
.soft([...persisted.workspaces.keys()].sort(), "persisted baseline")
.toEqual(["A", "B"]);
initializeSession();
restarted = new DirectorySync(serverId, callbacks, restartedCache);
restarted.connectionChanged(connection);
await restarted.refreshAll();
expect.soft(visibleIds(), "after restart and refresh").toEqual(["A", "B"]);
const beforeUpdate = visibleIds();
client.emit({
type: "workspace_update",
payload: { kind: "upsert", workspace: workspaceEntries[0], generation: "g", seq: 8 },
});
expect(visibleIds()).toEqual([...new Set([...beforeUpdate, "A"])].sort());
client.emit({
type: "workspace_update",
payload: { kind: "upsert", workspace: workspaceEntries[1], generation: "g", seq: 9 },
});
expect(visibleIds()).toEqual(["A", "B"]);
} finally {
directory.dispose();
restarted?.dispose();
await cache.flush();
await restartedCache.flush();
database.close();
}
},
);
@@ -61,6 +61,10 @@ interface AgentSnapshot {
syncRemovals: Array<{ id: string; seq: number }>;
}
interface WorkspaceRefreshSnapshot extends WorkspaceDirectorySnapshot {
readonly requestCursors: Readonly<Pick<DirectoryCheckpoint, "projects" | "workspaces">>;
}
interface AgentPageInfo {
hasMore?: boolean;
hasMoreAfter?: boolean;
@@ -133,7 +137,7 @@ export class DirectorySync {
AgentDirectoryDelta
>();
private readonly workspaceTransactions = new DirectoryTransactionOwner<
WorkspaceDirectorySnapshot,
WorkspaceRefreshSnapshot,
WorkspaceDirectoryDelta
>();
private readonly agents: AgentDirectoryReplica;
@@ -189,6 +193,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;
}
@@ -476,14 +482,20 @@ export class DirectorySync {
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) return;
const { client, source } = onlineConnection;
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
syncCursors: {},
syncModes: {},
touchedWorkspaceIds: new Set(),
touchedProjectIds: new Set(),
}));
const transaction = this.workspaceTransactions.begin(source, () => {
const baseline = this.workspaces.snapshot();
return {
workspaces: new Map(baseline.workspaces),
projects: new Map(baseline.projects),
// Cache hydration can advance the live replica while these requests await responses.
// Every page must use the cursors belonging to the maps captured here.
requestCursors: { ...this.cursors },
syncCursors: {},
syncModes: {},
touchedWorkspaceIds: new Set(),
touchedProjectIds: new Set(),
};
});
try {
await this.waitForSessionMetadata(client, source);
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
@@ -515,7 +527,7 @@ export class DirectorySync {
private async fetchWorkspaceSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
initialSubscribe: boolean,
supportsDirectorySync: boolean,
): Promise<void> {
@@ -525,7 +537,9 @@ export class DirectorySync {
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
...(supportsDirectorySync
? { sync: transaction.snapshot.requestCursors.workspaces ?? {} }
: {}),
};
let payload: FetchWorkspacesPayload;
if (subscribe) {
@@ -665,7 +679,7 @@ export class DirectorySync {
private assertWorkspaceTransactionCurrent(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
@@ -722,11 +736,13 @@ export class DirectorySync {
private async fetchProjectSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
supportsDirectorySync: boolean,
): Promise<void> {
const payload = await client.listProjects(
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
supportsDirectorySync
? { sync: transaction.snapshot.requestCursors.projects ?? {} }
: undefined,
);
this.assertWorkspaceTransactionCurrent(client, source, transaction);
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
@@ -751,7 +767,7 @@ export class DirectorySync {
private completeWorkspaceRefresh(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
@@ -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;
@@ -247,6 +256,41 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void {
}
describe("ReplicaCache", () => {
it("rejects pre-baseline-fix checkpoints without discarding cached rows or timelines", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const baseline = directory();
writer.replaceDirectoryBaseline(SERVER_ID, baseline);
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
await writer.flush();
await storage.apply({
deletes: [],
upserts: [
{
serverId: SERVER_ID,
kind: "checkpoint",
id: "singleton",
payload: JSON.stringify(baseline.checkpoint),
},
],
});
const reader = createCache(storage);
const restored = await reader.readDirectory(SERVER_ID);
expect(restored.checkpoint).toBeUndefined();
expect([...restored.workspaces.keys()]).toEqual([...baseline.workspaces.keys()]);
expect([...restored.projects.keys()]).toEqual([...baseline.projects.keys()]);
expect([...restored.agents.keys()]).toEqual([...baseline.agents.keys()]);
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline());
expect(await storage.read(SERVER_ID, ["checkpoint"])).toEqual([]);
reader.replaceDirectoryBaseline(SERVER_ID, baseline);
await reader.flush();
expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual(
baseline.checkpoint,
);
});
it("does nothing until an owner explicitly commits data", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
@@ -432,6 +476,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);
+42 -12
View File
@@ -378,6 +378,21 @@ const DirectoryCheckpointSchema = z.strictObject({
agents: DirectoryCursorSchema.optional(),
});
// Old checkpoints could describe a baseline that an overlapping refresh had discarded.
// Revalidate directory contents once without evicting cached rows or timelines.
const StoredDirectoryCheckpointSchema = z.strictObject({
version: z.literal(1),
cursors: DirectoryCheckpointSchema,
});
function serializeDirectoryCheckpoint(cursors: DirectoryCheckpoint) {
return { version: 1 as const, cursors };
}
function deserializeDirectoryCheckpoint(payload: string): DirectoryCheckpoint {
return parseStoredPayload(StoredDirectoryCheckpointSchema, payload).cursors;
}
function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null {
if (!stored) {
return null;
@@ -849,7 +864,7 @@ function applyDirectoryRow(
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
throw new Error("Replica checkpoint row id mismatch");
}
result.checkpoint = parseStoredPayload(DirectoryCheckpointSchema, row.payload);
result.checkpoint = deserializeDirectoryCheckpoint(row.payload);
return;
default:
return;
@@ -983,7 +998,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;
@@ -1011,7 +1030,7 @@ export class ReplicaCache {
let checkpoint: DirectoryCheckpoint | undefined;
if (checkpointRow) {
try {
checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload);
checkpoint = deserializeDirectoryCheckpoint(checkpointRow.payload);
checkpoint = { ...checkpoint };
delete checkpoint[invalidEntity];
} catch {
@@ -1027,7 +1046,7 @@ export class ReplicaCache {
serverId: row.serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
payload: JSON.stringify(checkpoint),
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
},
]
: [],
@@ -1053,7 +1072,7 @@ export class ReplicaCache {
serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
payload: JSON.stringify(checkpoint),
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
},
]
: [],
@@ -1173,20 +1192,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 +1231,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 {
@@ -1308,7 +1338,7 @@ export class ReplicaCache {
if (!value) return null;
break;
case "checkpoint":
value = upsert.value;
value = serializeDirectoryCheckpoint(upsert.value);
break;
}
return {
@@ -195,7 +195,7 @@ function ThemeRow({
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
{THEME_OPTIONS.map((option, index) => {
const previousOption = THEME_OPTIONS[index - 1];
return (
@@ -622,7 +622,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
{
restartServer: (reason) => daemonClient.restartServer(reason),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
...(await daemonClient.getDaemonStatus()),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
@@ -766,7 +766,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
void updateDaemonFromSettings(host.serverId, {
updateDaemon: () => daemonClient.updateDaemon(requestId),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
...(await daemonClient.getDaemonStatus()),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
@@ -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[];
}
@@ -1,4 +1,4 @@
import { page } from "@vitest/browser/context";
import { page, userEvent } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
@@ -40,6 +40,7 @@ interface MountedTerminal {
sizes: TerminalSize[];
terminalKeys: TerminalKeyRecord[];
inputModeChanges: TerminalInputModeState[];
openedUrls: string[];
}
const mountedTerminals: MountedTerminal[] = [];
@@ -95,6 +96,7 @@ function createTerminalHost(input: {
const inputs: string[] = [];
const terminalKeys: TerminalKeyRecord[] = [];
const inputModeChanges: TerminalInputModeState[] = [];
const openedUrls: string[] = [];
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({
callbacks: {
@@ -110,6 +112,9 @@ function createTerminalHost(input: {
onInputModeChange: (state) => {
inputModeChanges.push(state);
},
onOpenExternalUrl: (url) => {
openedUrls.push(url);
},
},
});
runtime.mount({
@@ -124,7 +129,16 @@ function createTerminalHost(input: {
},
});
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
const mounted = {
host,
root,
runtime,
inputs,
sizes,
terminalKeys,
inputModeChanges,
openedUrls,
};
mountedTerminals.push(mounted);
return mounted;
}
@@ -188,7 +202,43 @@ function dispatchTerminalKey(input: {
);
}
/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */
function hyperlink(input: { url: string; text: string }): string {
return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`;
}
function writeLines(mounted: MountedTerminal, lines: string[]): Promise<void> {
return new Promise((resolve) => {
mounted.runtime.write({
data: terminalOutput(lines.map((line) => `${line}\r\n`).join("")),
onCommitted: resolve,
});
});
}
async function clickTerminalLink(input: {
host: HTMLElement;
row: number;
col: number;
}): Promise<void> {
const terminal = getBrowserTerminal();
const screen = input.host.querySelector<HTMLElement>(".xterm-screen");
if (!screen) {
throw new Error("Expected xterm screen to be mounted");
}
const position = {
x: (input.col + 0.5) * (screen.clientWidth / terminal.cols),
y: (input.row + 0.5) * (screen.clientHeight / terminal.rows),
};
await userEvent.hover(screen, { position });
await waitFor({
predicate: () => input.host.querySelector(".xterm-cursor-pointer") !== null,
});
await userEvent.click(screen, { position });
}
afterEach(() => {
vi.restoreAllMocks();
for (const mounted of mountedTerminals.splice(0)) {
mounted.runtime.unmount();
mounted.root.remove();
@@ -526,4 +576,23 @@ describe("terminal emulator runtime in a real browser", () => {
expect(reset).not.toHaveBeenCalled();
});
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
await waitFor({ predicate: () => mounted.sizes.length > 0 });
await writeLines(mounted, [
hyperlink({ url: "https://example.com/osc8", text: "example" }),
"https://example.com/plain",
]);
await clickTerminalLink({ host: mounted.host, row: 1, col: 4 });
await clickTerminalLink({ host: mounted.host, row: 0, col: 2 });
expect(mounted.openedUrls).toEqual(["https://example.com/plain", "https://example.com/osc8"]);
expect(confirm).not.toHaveBeenCalled();
});
});
@@ -434,6 +434,10 @@ export class TerminalEmulatorRuntime {
this.inputModeTracker.reset();
this.emitInputModeChange();
const openExternalLink = (event: MouseEvent, uri: string) => {
event.preventDefault();
void this.callbacks.onOpenExternalUrl?.(uri);
};
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
@@ -441,6 +445,8 @@ export class TerminalEmulatorRuntime {
cursorStyle: "bar",
fontFamily: resolveTerminalFontFamily(input.fontFamily),
fontSize: resolveTerminalFontSize(input.fontSize),
// OSC 8 hyperlinks; without a handler xterm prompts and calls window.open().
linkHandler: { activate: openExternalLink },
lineHeight: 1.0,
macOptionIsMeta: true,
minimumContrastRatio: 1,
@@ -457,12 +463,7 @@ export class TerminalEmulatorRuntime {
let imageAddon: ImageAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void this.callbacks.onOpenExternalUrl?.(uri);
}),
);
terminal.loadAddon(new WebLinksAddon(openExternalLink));
const localFileLinkProvider = terminal.registerLinkProvider(
createTerminalLocalFileLinkProvider(terminal, {
resolveLink: async (source) => {
+11
View File
@@ -108,6 +108,17 @@ describe("formatMessageTimestamp", () => {
expect(formatted).toMatch(/10:12 PM|22:12/);
});
it("shows the full date for last week's same weekday, even under seven days ago", () => {
// 2026-09-18 is a Friday. The reply is 6 days 23h45m old and lands on today's weekday,
// so a weekday label would read as today.
const now = new Date(2026, 8, 25, 11, 47);
const date = new Date(2026, 8, 18, 12, 2);
const formatted = formatMessageTimestamp(date, now);
expect(formatted).toMatch(/Sep|September/);
expect(formatted).toMatch(/18/);
expect(formatted).not.toMatch(/Friday/);
});
it("includes full date for older timestamps", () => {
const now = new Date(2026, 4, 14, 17, 30);
const date = new Date(2026, 3, 1, 9, 5);
+13 -12
View File
@@ -91,12 +91,14 @@ export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string
return describeCompactTimeAgo(date, now).label;
}
function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
/**
* How many local midnights lie between two instants: 0 for the same day, 1 for yesterday.
* Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on
* today's weekday is 7, and rounded so a DST day of 23 or 25 hours still counts as one.
*/
function localCalendarDaysBetween(earlier: Date, later: Date): number {
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
return Math.round((startOfDay(later).getTime() - startOfDay(earlier).getTime()) / DAY_MS);
}
// Cached Intl formatter. Explicitly carrying `hourCycle` from the resolved
@@ -120,19 +122,18 @@ function getTimeFormatter(): Intl.DateTimeFormat {
/**
* Format a chat-message timestamp for hover-revealed UI.
* - Same day: "10:11 PM" or "22:11" depending on user preference
* - Within ~6 days: "Wednesday 10:11 PM"
* - Older: "14 May 2026, 10:11 PM"
* - The previous 6 calendar days: "Wednesday 10:11 PM"
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
*/
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
const time = getTimeFormatter().format(date);
const daysAgo = localCalendarDaysBetween(date, now);
if (isSameLocalDay(date, now)) {
if (daysAgo === 0) {
return time;
}
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays >= 0 && diffDays < 7) {
if (daysAgo > 0 && daysAgo < 7) {
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
return `${weekday} ${time}`;
}
+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.1",
"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.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"@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",
+43 -4
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import {
resolveExistingRunWorkspace,
resolveRunCallerAgentId,
@@ -8,13 +9,51 @@ import {
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
// Answers fetchAgent the way a daemon does: an unknown id is an error.
function daemonWithAgents(...agentIds: string[]) {
return {
async fetchAgent({ agentId }: { agentId: string }) {
if (!agentIds.includes(agentId)) {
throw new Error(`Agent not found: ${agentId}`);
}
return { agent: { id: agentId } };
},
};
}
describe("managed agent caller context", () => {
it("propagates a trimmed PASEO_AGENT_ID", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => {
await expect(
resolveRunCallerAgentId(daemonWithAgents("parent-agent"), {
PASEO_AGENT_ID: " parent-agent ",
}),
).resolves.toBe("parent-agent");
});
it("omits blank caller ids", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
it("runs without a caller when PASEO_AGENT_ID belongs to another daemon", async () => {
await expect(
resolveRunCallerAgentId(daemonWithAgents("other-agent"), {
PASEO_AGENT_ID: "parent-agent",
}),
).resolves.toBeUndefined();
});
it("fails instead of dropping the caller when the lookup loses its connection", async () => {
const disconnectedDaemon = {
async fetchAgent(): Promise<never> {
throw new DaemonConnectionError("Connection lost before message could be sent");
},
};
await expect(
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
).rejects.toBeInstanceOf(DaemonConnectionError);
});
it("omits blank caller ids", async () => {
await expect(
resolveRunCallerAgentId(daemonWithAgents(), { PASEO_AGENT_ID: " " }),
).resolves.toBeUndefined();
});
});
+29 -7
View File
@@ -4,6 +4,7 @@ import {
StructuredAgentResponseError,
} from "@getpaseo/server/agent-response";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
@@ -526,7 +527,7 @@ export async function resolveExistingRunWorkspace(
// Workspace policy for `paseo run`. Precedence:
// 1. --workspace <id> -> run in that existing workspace
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
// 2. caller agent -> daemon resolves the caller's workspace
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
// 4. --new-workspace <kind> -> mint a new workspace explicitly
// 5. bare run -> mint a new local-backed workspace for cwd
@@ -534,6 +535,7 @@ async function resolveRunWorkspace(
client: ConnectedDaemonClient,
options: AgentRunOptions,
cwd: string,
callerAgentId: string | undefined,
): Promise<RunWorkspace> {
const newWorkspace = resolveNewWorkspaceKind(options);
const explicit = newWorkspace ? undefined : options.workspace?.trim();
@@ -542,7 +544,7 @@ async function resolveRunWorkspace(
return resolveExistingRunWorkspace(client, explicit);
}
if (!newWorkspace && resolveRunCallerAgentId()) {
if (!newWorkspace && callerAgentId) {
return { cwd };
}
@@ -609,9 +611,9 @@ export async function runRunCommand(
const env = parseRunEnv(options.env);
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
const workspace = await resolveRunWorkspace(client, options, cwd);
const callerAgentId = await resolveRunCallerAgentId(client);
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
const workspaceId = workspace.id;
const callerAgentId = resolveRunCallerAgentId();
const runCwd = workspace.cwd;
if (outputSchema) {
@@ -743,8 +745,28 @@ export async function runRunCommand(
}
}
export function resolveRunCallerAgentId(
export interface RunCallerLookupClient {
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
}
// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run
// sent to another daemon (--host or --home) has no caller there, so it runs as
// a top-level agent instead of failing on an unknown caller.
export async function resolveRunCallerAgentId(
client: RunCallerLookupClient,
env: { PASEO_AGENT_ID?: string } = process.env,
): string | undefined {
return env.PASEO_AGENT_ID?.trim() || undefined;
): Promise<string | undefined> {
const agentId = env.PASEO_AGENT_ID?.trim();
if (!agentId) {
return undefined;
}
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
// A daemon without this agent answers with an error. A lost or timed-out
// connection is not an answer, so it must not drop the caller.
if (error instanceof DaemonConnectionError) {
throw error;
}
return null;
});
return caller?.agent.id === agentId ? agentId : undefined;
}
@@ -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]!;
@@ -57,6 +57,17 @@ function createCommandError(code: string, message: string, details?: string): Co
return { code, message, ...(details ? { details } : {}) };
}
function terminalPasswordPrompt(): PromptPassword {
if (!process.stdin.isTTY) {
throw createCommandError(
"PASSWORD_TTY_REQUIRED",
"paseo daemon set-password needs a terminal to read the password",
"Run it in an interactive terminal, or set PASEO_PASSWORD in the daemon's environment instead.",
);
}
return (message) => passwordPrompt({ message });
}
async function promptForPassword(promptPassword: PromptPassword): Promise<string> {
const first = await promptPassword("New daemon password");
if (isCancel(first)) {
@@ -112,7 +123,7 @@ export async function runSetPasswordCommand(
const promptPassword =
typeof options.promptPassword === "function"
? (options.promptPassword as PromptPassword)
: (message: string) => passwordPrompt({ message });
: terminalPasswordPrompt();
const newPassword = await promptForPassword(promptPassword);
const result = await setDaemonPasswordInConfig(newPassword, {
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
@@ -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,6 +6,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Command } from "commander";
import { isBearerTokenValid } from "@getpaseo/server/auth";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import {
runSetPasswordCommand,
setDaemonPasswordInConfig,
@@ -105,6 +106,27 @@ try {
);
console.log("✓ command refuses password mismatch\n");
}
{
console.log("Test 4: piped stdin fails with a message instead of exiting silently");
const pipedHome = join(root, "piped");
await mkdir(pipedHome, { recursive: true });
const run = runLocalPaseo(["daemon", "set-password", "--home", pipedHome, "--json"]);
run.stdin.end("x\nx\n");
const result = await run;
assert.strictEqual(result.exitCode, 1, result.stderr);
const error = JSON.parse(result.stderr).error as {
code: string;
message: string;
details: string;
};
assert.strictEqual(error.code, "PASSWORD_TTY_REQUIRED");
assert.match(error.message, /needs a terminal/);
assert.match(error.details, /PASEO_PASSWORD/);
await assert.rejects(readFile(join(pipedHome, "config.json"), "utf-8"));
console.log("✓ piped stdin reports that a terminal is required\n");
}
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -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.1",
"version": "0.9.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@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.1",
"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.1",
"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.1",
"version": "0.9.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.9.1",
"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.1",
"@getpaseo/protocol": "0.9.1",
"@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.1",
"@getpaseo/protocol": "0.9.1",
"@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.1",
"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.1",
"version": "0.9.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+9 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.9.1",
"version": "0.9.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -82,13 +82,14 @@
"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",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -106,14 +107,15 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@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",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -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(" "),
);
});
@@ -4,22 +4,27 @@ import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const sourcePath = fileURLToPath(
new URL("../src/server/agent/providers/opencode/bridge-plugin.mjs", import.meta.url),
);
const outputPath = fileURLToPath(
new URL(
"../dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs",
import.meta.url,
),
);
await mkdir(path.dirname(outputPath), { recursive: true });
await build({
entryPoints: [sourcePath],
bundle: true,
format: "esm",
platform: "neutral",
target: "es2022",
outfile: outputPath,
});
for (const directory of ["", "v2/"]) {
const sourcePath = fileURLToPath(
new URL(
`../src/server/agent/providers/opencode/${directory}bridge-plugin.mjs`,
import.meta.url,
),
);
const outputPath = fileURLToPath(
new URL(
`../dist/server/server/agent/providers/opencode/${directory}bridge-plugin.bundle.mjs`,
import.meta.url,
),
);
await mkdir(path.dirname(outputPath), { recursive: true });
await build({
entryPoints: [sourcePath],
bundle: true,
format: "esm",
platform: "neutral",
mainFields: ["module", "main"],
target: "es2022",
outfile: outputPath,
});
}
@@ -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));
});
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -23,6 +23,10 @@ async function runSupervisorFixture(options: {
workerSource: string;
restartOnCrash?: boolean;
timeoutMs?: number;
/** POSIX RLIMIT_FSIZE for the supervisor, in 512-byte blocks: writes past it fail like a full disk. */
fileSizeLimitBlocks?: number;
/** Occupy the log path with a directory so the supervisor cannot open daemon.log. */
blockLogPath?: boolean;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
@@ -36,9 +40,12 @@ async function runSupervisorFixture(options: {
const workerPath = path.join(tempDir, "worker.mjs");
const runnerPath = path.join(tempDir, "runner.mjs");
if (options.blockLogPath) {
await mkdir(logPath);
}
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,
@@ -62,7 +69,18 @@ async function runSupervisorFixture(options: {
);
const startedAt = Date.now();
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
const runnerArgs = [process.execPath, "--import", "tsx", runnerPath];
const [command, ...args] =
options.fileSizeLimitBlocks === undefined
? runnerArgs
: [
"/bin/sh",
"-c",
`ulimit -f ${options.fileSizeLimitBlocks} && exec "$@"`,
"sh",
...runnerArgs,
];
const child = spawn(command, args, {
cwd: repoRoot,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
@@ -348,4 +366,55 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain("Supervisor exiting");
},
);
// POSIX-only: RLIMIT_FSIZE is how the test makes daemon.log writes fail.
test.skipIf(isPlatform("win32"))(
"keeps supervising the worker when daemon.log can no longer be written",
async () => {
const result = await runSupervisorFixture({
fileSizeLimitBlocks: 128,
workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
const line = '{"level":30,"msg":"' + "x".repeat(1000) + '"}\\n';
for (let i = 0; i < 200; i += 1) process.stdout.write(line);
setTimeout(() => {
process.send?.({ type: "paseo:shutdown", reason: "log_write_failure_probe" });
}, 1000);
setInterval(() => {}, 1000);
`,
});
expect(result.stderr).not.toContain("Unhandled 'error' event");
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
},
);
test("resumes writing daemon.log once the log path is writable again", async () => {
const result = await runSupervisorFixture({
blockLogPath: true,
workerSource: `
import { rmdirSync } from "node:fs";
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
process.stdout.write("line while daemon.log is blocked\\n");
setTimeout(() => {
rmdirSync(process.argv[1].replace(/worker\\.mjs$/, "daemon.log"));
process.stdout.write("line after daemon.log is writable\\n");
setTimeout(() => {
process.send?.({ type: "paseo:shutdown", reason: "log_recovery_probe" });
}, 200);
}, 500);
setInterval(() => {}, 1000);
`,
});
expect(result.code).toBe(0);
expect(result.log).toContain("line after daemon.log is writable\n");
expect(result.log).toContain('"reason":"log_recovery_probe"');
});
});
@@ -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,
);
+73 -26
View File
@@ -1,7 +1,9 @@
import { fork, spawn, type ChildProcess } from "child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
import {
createStream as createRotatingFileStream,
type RotatingFileStream,
} from "rotating-file-stream";
import { signalProcessTree } from "../src/utils/tree-kill.js";
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
@@ -23,6 +25,7 @@ type WorkerLifecycleMessage =
| {
type: "paseo:ready";
listen: string;
serverId: string;
}
| {
type: "paseo:restart";
@@ -50,7 +53,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 +81,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;
@@ -106,17 +112,58 @@ function toRotatingFileStreamSize(size: string): string {
return `${value}${unit}`;
}
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
if (!options) {
return null;
}
// daemon.log is best effort: a full disk or a replaced log path must not take the
// supervisor, and with it the worker, down. A failed stream is dropped and a new one
// is opened on the next write, so logging resumes once the file is writable again.
function createDurableLog(
options: SupervisorLogFileOptions | undefined,
reportFailure: (message: string) => void,
) {
let stream: RotatingFileStream | null = null;
let failing = false;
mkdirSync(path.dirname(options.path), { recursive: true });
return createRotatingFileStream(path.basename(options.path), {
path: path.dirname(options.path),
size: toRotatingFileStreamSize(options.rotate.maxSize),
maxFiles: options.rotate.maxFiles,
});
const open = (logFile: SupervisorLogFileOptions): RotatingFileStream => {
const next = createRotatingFileStream(path.basename(logFile.path), {
path: path.dirname(logFile.path),
size: toRotatingFileStreamSize(logFile.rotate.maxSize),
maxFiles: logFile.rotate.maxFiles,
});
next.on("error", (error) => {
if (stream === next) {
stream = null;
}
if (!failing) {
failing = true;
reportFailure(
`Cannot write ${logFile.path}, will retry on the next log line: ${error.message}`,
);
}
});
return next;
};
return {
write(chunk: string | Buffer): void {
if (!options) {
return;
}
stream ??= open(options);
stream.write(chunk, (error) => {
if (!error) {
failing = false;
}
});
},
close(): Promise<void> {
return new Promise((resolve) => {
if (!stream) {
resolve();
return;
}
stream.end(() => resolve());
});
},
};
}
export function runSupervisor(options: SupervisorOptions): SupervisorController {
@@ -133,10 +180,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
let lifecycleFailed = false;
let publication = Promise.resolve();
let forceKillTimer: NodeJS.Timeout | null = null;
const logStream = createSupervisorLogStream(options.logFile);
const durableLog = createDurableLog(options.logFile, (message) => {
process.stderr.write(`[${options.name}] ${message}\n`);
});
const writeDurableChunk = (chunk: string | Buffer): void => {
logStream?.write(chunk);
durableLog.write(chunk);
};
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
@@ -157,14 +206,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
writeLifecycleLog(message);
};
const closeLogStream = (): Promise<void> =>
new Promise((resolve) => {
if (!logStream) {
resolve();
return;
}
logStream.end(resolve);
});
const closeLogStream = (): Promise<void> => durableLog.close();
const exitSupervisor = (code: number): void => {
if (exiting) {
@@ -289,7 +331,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;
@@ -11113,3 +11244,129 @@ test("concurrent native restores run once before resuming the same agent", async
rmSync(workdir, { recursive: true, force: true });
}
});
test("commits startup notices once on create and after restored history", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-startup-notice-"));
const notice: AgentTimelineItem = { type: "notification", level: "info", message: "Runtime v2" };
const history: AgentTimelineItem = { type: "assistant_message", text: "Existing conversation" };
const toolOutput: AgentTimelineItem = {
type: "tool_call",
callId: "startup-output",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "print output",
output: "x".repeat(1024 * 1024),
exitCode: 0,
},
};
const limitedToolOutput: AgentTimelineItem = {
...toolOutput,
detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 },
};
class NotifyingSession extends TestAgentSession {
readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }];
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: history,
timestamp: "2026-09-21T00:00:00.000Z",
};
yield {
type: "timeline",
provider: "codex",
item: toolOutput,
timestamp: "2026-09-21T01:00:00.000Z",
};
yield { type: "timeline", provider: "codex", ...this.initialTimeline[0] };
}
}
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig) {
return new NotifyingSession(config);
}
override async resumeSession() {
return new NotifyingSession({ provider: "codex", cwd: workdir });
}
})();
const store = new RecordingTimelineStore();
const manager = new AgentManager({
clients: { codex: client },
durableTimelineStore: store,
logger,
});
const ids: string[] = [];
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {});
ids.push(created.id);
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
const resumed = await manager.resumeAgentFromPersistence({
provider: "codex",
sessionId: "existing",
metadata: { cwd: workdir },
});
ids.push(resumed.id);
await manager.hydrateTimelineFromProvider(resumed.id);
expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([
history,
limitedToolOutput,
notice,
]);
await manager.hydrateTimelineFromProvider(resumed.id, { force: true });
expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([
history,
limitedToolOutput,
notice,
]);
await manager.flush();
expect(store.writes.flat()).toContainEqual(expect.objectContaining({ item: notice }));
} finally {
for (const id of ids) await manager.closeAgent(id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("failed startup history closes the session without registering an agent", async () => {
let closed = false;
class FailingHistorySession extends TestAgentSession {
readonly initialTimeline = [
{
item: { type: "notification", level: "info", message: "Runtime v2" } as const,
timestamp: "2026-09-22T00:00:00.000Z",
},
];
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "Partial history" },
};
throw new Error("History unavailable");
}
override async close() {
closed = true;
await super.close();
}
}
const client = new (class extends TestAgentClient {
override async resumeSession() {
return new FailingHistorySession({ provider: "codex", cwd: tmpdir() });
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
await expect(
manager.resumeAgentFromPersistence({
provider: "codex",
sessionId: "failed-history",
metadata: { cwd: tmpdir() },
}),
).rejects.toThrow("History unavailable");
expect({ agents: manager.listAgents(), closed }).toEqual({ agents: [], closed: true });
} finally {
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
}
});
+113 -54
View File
@@ -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,
},
);
@@ -3459,11 +3488,31 @@ export class AgentManager {
options,
});
// Read history before publishing the agent: a provider failure must leave the
// session unregistered so the registration catch closes it.
const startupHistory: AgentStreamEvent[] = [];
if (session.initialTimeline?.length && !managed.historyPrimed) {
for await (const event of session.streamHistory()) {
startupHistory.push(limitAgentStreamEventContent(event));
}
}
this.assertAcceptingAgentRegistrations();
this.agents.set(resolvedAgentId, managed);
registered = true;
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
if (session.initialTimeline?.length) {
if (!managed.historyPrimed) {
// Legacy/imported chats need their existing history before startup rows.
await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory);
} else {
for (const entry of session.initialTimeline) {
this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp });
}
}
this.refreshSessionPersistence(managed);
}
await this.refreshRuntimeInfo(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
await this.persistSnapshot(managed, {
@@ -3994,25 +4043,22 @@ export class AgentManager {
private async primeTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
broadcast: boolean | (() => boolean),
history:
| AsyncIterable<AgentStreamEvent>
| Iterable<AgentStreamEvent> = agent.session.streamHistory(),
): 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 {
for await (const rawEvent of agent.session.streamHistory()) {
// 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 history) {
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 +4067,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 +5067,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 +5144,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-"));
@@ -425,6 +425,11 @@ interface NotifySafelyOptions {
permissionRequest?: AgentPermissionRequest;
}
// A caller waits on a child through one armed notification. Arming again, such as a
// follow-up prompt while the child still runs, replaces the earlier one so the child's
// next finish reaches the caller once.
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
const {
agentManager,
@@ -440,10 +445,19 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
let unsubscribe: (() => void) | null = null;
let notificationQueue = Promise.resolve();
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
armedFinishNotifications.set(agentManager, armedByManager);
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
armedByManager.get(armedKey)?.();
armedByManager.set(armedKey, stop);
function stop(): void {
if (stopped) return;
stopped = true;
unsubscribe?.();
if (armedByManager.get(armedKey) === stop) {
armedByManager.delete(armedKey);
}
}
async function notify(

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