Compare commits

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

Closes #5328

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

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

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

Closes #5300

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

* chore: rerun CI after macOS file observation flake

---------

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

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

Closes #5327

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

* Retrigger CI after website dev server startup timeout

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

---------

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

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

* Make the local create_workspace e2e assertions path-format independent

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

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

* Clean up the offline MCP daemon when its startup fails

---------

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

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

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

Closes #4370

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

* Query the question card test by role and accessible name

---------

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

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

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

---------

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

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

Closes #4683

* Strip the config BOM with an anchored replace

---------

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

Fixes #4508

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

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

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

Closes #4732

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

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

Closes #4745

* Skip only schedule files with invalid content

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

* Retrigger CI after an unrelated macOS observation-test flake

---------

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

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

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

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

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

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

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

* Harden the reload teardown test for Windows and clean shutdown

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

* Verify plugin reload teardown and reject sends during close

* Synchronize plugin reload teardown test with close lifecycle

---------

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

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

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

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

Three redundant passes are removed:

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

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

Refs #4879

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

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

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

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

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

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

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

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

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

---------

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

Closes #5215

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

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

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

Fixes #5102

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

Closes #4781.
2026-09-23 21:54:46 +02:00
80 changed files with 2372 additions and 300 deletions
+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
+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.
+1 -1
View File
@@ -10,7 +10,7 @@ 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. 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.
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. `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.
+5 -2
View File
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
## Session grants and agent operations
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
workspace through `workspace.title.set.request` without `workspace.manage`.
Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.
+1 -1
View File
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
+14 -5
View File
@@ -8,7 +8,8 @@ This guide walks through adding a new agent provider end-to-end. There are two i
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
top-level provider options.
This Paseo version accepts these keys:
@@ -19,10 +20,18 @@ This Paseo version accepts these keys:
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
`settings`. `providerOptions.extraArgs` passes the SDK's documented
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
@@ -0,0 +1,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
+27 -27
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": [
@@ -36196,7 +36196,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 +37262,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 +37514,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 +37528,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.1",
"version": "0.9.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37772,7 +37772,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.1",
"version": "0.9.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38668,7 +38668,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 +38697,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 +38714,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 +38737,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 +38764,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,16 +38982,16 @@
},
"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",
@@ -39895,7 +39895,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": [
+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": {
@@ -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({
@@ -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;
}
+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",
@@ -5,6 +5,7 @@ import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { hashDaemonPassword } from "@getpaseo/server/auth";
import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server/daemon-control";
import { expect, test } from "vitest";
import { connectToDaemon } from "../../utils/client.js";
@@ -196,6 +197,26 @@ test("removed flags and ambiguous targets fail before side effects; observation
}
}, 30_000);
test("status reports the server id of a password-protected daemon to a caller without the password", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const configPath = path.join(home, "config.json");
const config = JSON.parse(await readFile(configPath, "utf8"));
config.daemon.auth = { password: await hashDaemonPassword("secret") };
await writeFile(configPath, JSON.stringify(config));
await f.ok(["start", "--home", home, "--timeout", "30"]);
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
connectedDaemon: "auth_required",
serverId: authenticated.serverId,
});
} finally {
await f.close();
}
}, 60_000);
test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
@@ -591,6 +612,25 @@ test("malformed configuration cannot turn a readiness timeout into launch cancel
}
}, 60_000);
test("a background start that fails before the supervisor logs names the cause in a log that exists", async () => {
const f = await fixture();
const home = f.homes[0]!;
const configPath = path.join(home, "config.json");
try {
await mkdir(home, { recursive: true });
await writeFile(configPath, '{"version":1,');
const failed = await f.run(["daemon", "start", "--home", home, "--timeout", "30"]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain(`Logs: ${path.join(home, "daemon.log")}`);
expect(failed.stderr).toContain(`Invalid JSON in ${configPath}`);
expect(await readFile(path.join(home, "daemon.log"), "utf8")).toContain(
`Invalid JSON in ${configPath}`,
);
} finally {
await f.close();
}
}, 60_000);
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
const f = await fixture();
const home = f.homes[0]!;
@@ -124,6 +124,7 @@ function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemo
pid: instance?.pid ?? null,
startedAt: instance?.startedAt ?? null,
listen: instance?.listen ?? null,
serverId: instance?.serverId ?? undefined,
hostname: instance?.hostname ?? null,
configuredListen: config.listen,
localDaemon,
+17 -8
View File
@@ -16,7 +16,7 @@ export interface PermissionResponseItem {
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
idField: "requestId",
columns: [
{ header: "REQUEST ID", field: "requestId", width: 12 },
{ header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 },
{ header: "AGENT", field: "agentShortId", width: 10 },
{ header: "TOOL", field: "name", width: 20 },
{
@@ -32,6 +32,21 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
],
};
/** Transform a permission the agent answered to a response item */
export function toPermissionResponseItem(
agentId: string,
permission: AgentPermissionRequest,
result: "allowed" | "denied",
): PermissionResponseItem {
return {
requestId: permission.id,
agentId,
agentShortId: agentId.slice(0, 7),
name: permission.name,
result,
};
}
export type PermitAllowResult = ListResult<PermissionResponseItem>;
export interface PermitAllowOptions extends CommandOptions {
@@ -118,13 +133,7 @@ export async function runAllowCommand(
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
return {
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: "allowed",
};
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
+6 -8
View File
@@ -2,7 +2,11 @@ import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
import {
permitResponseSchema,
toPermissionResponseItem,
type PermissionResponseItem,
} from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>;
@@ -83,13 +87,7 @@ export async function runDenyCommand(
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
return {
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: "denied",
};
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
+19 -17
View File
@@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: "id",
columns: [
{ header: "AGENT", field: "agentShortId", width: 12 },
{ header: "REQ_ID", field: "id", width: 12 },
{ header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 },
{ header: "TOOL", field: "name", width: 20 },
{ header: "DESCRIPTION", field: "description", width: 50 },
],
@@ -30,7 +30,7 @@ function toListItem(
permission: AgentPermissionRequest,
): PermissionListItem {
return {
id: permission.id.slice(0, 8),
id: permission.id,
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
name: permission.name,
@@ -40,6 +40,22 @@ function toListItem(
export type PermitLsResult = ListResult<PermissionListItem>;
/** Collect the pending permissions of every agent */
export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult {
const items: PermissionListItem[] = [];
for (const agent of agents) {
for (const permission of agent.pendingPermissions ?? []) {
items.push(toListItem(agent, permission));
}
}
return {
type: "list",
data: items,
schema: permitLsSchema,
};
}
export interface PermitLsOptions extends CommandOptions {
host?: string;
}
@@ -55,21 +71,7 @@ export async function runLsCommand(
const agents = agentsPayload.entries.map((entry) => entry.agent);
await client.close();
// Collect all pending permissions from all agents
const items: PermissionListItem[] = [];
for (const agent of agents) {
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
for (const permission of agent.pendingPermissions) {
items.push(toListItem(agent, permission));
}
}
}
return {
type: "list",
data: items,
schema: permitLsSchema,
};
return listPendingPermissions(agents);
} catch (err) {
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,45 @@
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { permitResponseSchema, toPermissionResponseItem } from "./allow.js";
import { listPendingPermissions } from "./ls.js";
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
function permissionRequest(id: string): AgentPermissionRequest {
return { id, provider: "claude", name: "Write", kind: "tool" };
}
const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783");
const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb");
const agent = {
id: agentId,
pendingPermissions: [writeRequest, editRequest],
} as AgentSnapshotPayload;
describe("permit ls output", () => {
it("emits full request ids that permit allow accepts", () => {
const result = listPendingPermissions([agent]);
const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>;
expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]);
expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]);
});
});
describe("permit allow and deny output", () => {
it("emits the full id of the request that was answered", () => {
const result = {
type: "list" as const,
data: [toPermissionResponseItem(agentId, writeRequest, "allowed")],
schema: permitResponseSchema,
};
const answered = JSON.parse(render(result, { format: "json" })) as Array<{
requestId: string;
}>;
expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]);
});
});
+11 -4
View File
@@ -52,13 +52,20 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
return {
code,
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
details:
options.target.kind === "instance"
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
details: describeConnectionRemedy(code, options.target),
};
}
function describeConnectionRemedy(code: string, target: DaemonTarget): string {
if (code === "AUTH_REQUIRED")
return "The daemon requires a password. Set PASEO_PASSWORD and retry.";
if (code === "AUTH_FAILED")
return "The daemon rejected the password. Check PASEO_PASSWORD and retry.";
if (target.kind === "instance")
return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`;
return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.";
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
@@ -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",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.9.1",
"version": "0.9.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.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",
@@ -1,5 +1,5 @@
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import {
acquirePidLock,
@@ -9,6 +9,8 @@ import {
updatePidLock,
} from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { daemonLogPath } from "../src/server/daemon-instance.js";
import { PRIVATE_FILE_MODE } from "../src/server/private-files.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
@@ -118,9 +120,7 @@ async function main(): Promise<void> {
});
} catch (error) {
if (error instanceof PidLockError) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
return;
failStartup(error.message, error.message);
}
throw error;
}
@@ -172,17 +172,41 @@ async function main(): Promise<void> {
: undefined,
restartOnCrash: true,
logFile: supervisorLogFile,
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
onWorkerReady: async ({ listen, serverId }) => {
await updatePidLock(paseoHome, { listen, serverId }, { ownerPid: process.pid });
},
onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }),
onWorkerExit: () =>
updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }),
onSupervisorExit: releaseLock,
});
requestSupervisorShutdown = supervisor.requestShutdown;
}
void main().catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
// The supervisor opens its log only after config and the PID lock succeed. A background
// launch discards stderr, so earlier failures also go to the log the launcher points at.
function failStartup(detail: string, summary: string): never {
process.stderr.write(`${detail}\n`);
try {
const logPath = daemonLogPath(resolvePaseoHome(process.env));
mkdirSync(path.dirname(logPath), { recursive: true });
appendFileSync(
logPath,
`${JSON.stringify({
level: "fatal",
time: new Date().toISOString(),
pid: process.pid,
name: "DaemonRunner",
msg: summary,
})}\n`,
{ mode: PRIVATE_FILE_MODE },
);
} catch {
// stderr already carries the failure.
}
process.exit(1);
}
void main().catch((error) => {
if (error instanceof Error) failStartup(error.stack ?? error.message, error.message);
failStartup(String(error), String(error));
});
@@ -38,7 +38,7 @@ async function runSupervisorFixture(options: {
await writeFile(
workerPath,
`process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`,
`process.send?.({ type: "paseo:ready", listen: "fixture", serverId: "srv_fixture" });\n${options.workerSource}`,
);
await writeFile(
runnerPath,
@@ -92,7 +92,7 @@ test("a replacement worker must independently reach ready", async () => {
process.on("message", message => {
if (message.type === "paseo:graceful-shutdown") process.exit(0);
});
process.send({ type: "paseo:ready", listen: "test-endpoint" });
process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" });
process.send({ type: "paseo:restart" });
`);
expect(result.code).toBe(1);
@@ -102,7 +102,7 @@ test("a replacement worker must independently reach ready", async () => {
test("exit clears publication after an in-flight ready write", async () => {
const result = await runWorker(
`
process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0));
process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }, () => process.exit(0));
`,
100,
);
+13 -4
View File
@@ -23,6 +23,7 @@ type WorkerLifecycleMessage =
| {
type: "paseo:ready";
listen: string;
serverId: string;
}
| {
type: "paseo:restart";
@@ -50,7 +51,7 @@ interface SupervisorOptions {
args: string[];
env?: NodeJS.ProcessEnv;
} | null;
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
onWorkerReady?: (message: { listen: string; serverId: string }) => Promise<void> | void;
onWorkerExit?: () => Promise<void> | void;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void;
@@ -78,11 +79,14 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
};
}
if (type === "paseo:ready") {
const listen = (msg as { listen?: unknown }).listen;
const { listen, serverId } = msg as { listen?: unknown; serverId?: unknown };
if (typeof listen !== "string" || listen.trim().length === 0) {
return null;
}
return { type: "paseo:ready", listen };
if (typeof serverId !== "string" || serverId.trim().length === 0) {
return null;
}
return { type: "paseo:ready", listen, serverId };
}
if (type === "paseo:restart") {
const reason = (msg as { reason?: unknown }).reason;
@@ -289,7 +293,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
reachedReady = true;
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
publication = publication
.then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen }))
.then(() =>
options.onWorkerReady?.({
listen: lifecycleMessage.listen,
serverId: lifecycleMessage.serverId,
}),
)
.catch((error) => {
lifecycleFailed = true;
const message = error instanceof Error ? error.message : String(error);
@@ -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-"));
@@ -4,9 +4,10 @@ import type {
ProviderInput,
ProviderRegistration,
} from "@getpaseo/plugin/server/provider";
import { setImmediate as nextTurn } from "node:timers/promises";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js";
import { PluginAgentClientRegistry } from "./plugin-provider.js";
import {
isStaleProviderSessionError,
@@ -25,6 +26,7 @@ interface ProviderHarnessOptions {
capabilities?: ProviderConnection["capabilities"];
completeTurn?: boolean;
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
function createProviderHarness(options: ProviderHarnessOptions = {}) {
@@ -43,6 +45,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
capabilities,
async send(input) {
inputs.push(input);
if (await options.handleInput?.(input, emit)) return;
if (input.type === "catalog") {
emit({
type: "catalog",
@@ -278,6 +281,59 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
}
describe("PluginAgentClientRegistry", () => {
test.each([false, true])(
"contains a failed session open while send is pending: %s",
async (pendingSend) => {
let listener: ((event: ProviderEvent) => void) | undefined;
const unhandled: unknown[] = [];
const observeUnhandled = (reason: unknown) => unhandled.push(reason);
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([
{
id: "failing-provider",
label: "Failing provider",
async connect() {
return {
version: 1,
capabilities: ["session.persistence"],
async send(input) {
if (input.type !== "session.open") return;
listener?.({
type: "request.failed",
requestId: input.requestId,
error: { message: "OMP persistent session registration is in progress" },
});
// Keep acceptance pending across a Node event-loop turn, as IPC can do.
if (pendingSend) await nextTurn();
},
onEvent(nextListener) {
listener = nextListener;
return () => {
listener = undefined;
};
},
async close() {},
};
},
},
]);
process.on("unhandledRejection", observeUnhandled);
try {
await expect(
registry.clients()["failing-provider"]!.createSession({
provider: "failing-provider",
cwd: "/workspace",
}),
).rejects.toThrow("OMP persistent session registration is in progress");
await nextTurn();
expect(unhandled).toEqual([]);
} finally {
await registry.shutdown();
process.off("unhandledRejection", observeUnhandled);
}
},
);
test("preserves nested provider child ownership during opening", async () => {
const harness = createProviderHarness({ openChildren: openNestedChildren });
const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -540,3 +596,167 @@ describe("PluginAgentClientRegistry", () => {
expect(harness.inputs.map((input) => input.type)).toContain("session.close");
});
});
type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt";
async function requestFromClient(client: AgentClient, kind: RequestKind): Promise<unknown> {
if (kind === "catalog") return client.fetchCatalog({ scope: "global" });
const session = await client.createSession({ provider: client.provider, cwd: "/workspace" });
if (kind === "session.open") return session;
if (kind === "session.configure") return session.setMode("build");
return session.startTurn("hello", { clientMessageId: "pending-message" });
}
async function withObservedProvider(
options: ProviderHarnessOptions,
run: (client: AgentClient, registry: PluginAgentClientRegistry) => Promise<void>,
): Promise<void> {
const harness = createProviderHarness(options);
const registry = new PluginAgentClientRegistry(createTestLogger());
const unhandled: unknown[] = [];
const observe = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", observe);
registry.replace([harness.registration]);
try {
await run(registry.clients()[harness.registration.id]!, registry);
await registry.shutdown();
await nextTurn();
expect(unhandled).toEqual([]);
} finally {
await registry.shutdown();
process.off("unhandledRejection", observe);
}
}
describe("pending provider responses", () => {
test.each<RequestKind>(["session.open", "catalog", "session.configure", "session.prompt"])(
"preserves the send error for %s",
async (kind) => {
const failure = new Error("Provider send failed");
await withObservedProvider(
{
async handleInput(input) {
if (input.type === kind) throw failure;
return false;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toBe(failure);
},
);
},
);
test.each([
[
"catalog",
expect.objectContaining({
models: [expect.objectContaining({ id: "plugin-model", isDefault: true })],
defaultModeId: "build",
}),
],
["session.configure", undefined],
] as const)(
"contains an early request.failed for %s and permits a successful retry",
async (kind, result) => {
let failed = false;
await withObservedProvider(
{
async handleInput(input, emit) {
if (input.type !== kind || !("requestId" in input) || failed) return false;
failed = true;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "Provider request failed", code: "busy", diagnostic: "retry" },
});
await nextTurn();
return true;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toMatchObject({
message: "Provider request failed",
code: "busy",
diagnostic: "retry",
});
await expect(requestFromClient(client, kind)).resolves.toEqual(result);
},
);
},
);
test.each([
["session.open", "Provider session closed"],
["session.configure", "Provider session closed"],
["session.prompt", StaleProviderSessionError],
] as const)("contains session closure while accepting %s", async (kind, error) => {
await withObservedProvider(
{
async handleInput(input, emit) {
if (input.type !== kind || !("sessionId" in input)) return false;
emit({
type: "session.closed",
sessionId: input.sessionId,
error: { message: "Provider session closed" },
});
await nextTurn();
return true;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
},
);
});
test.each([
["session.open", "Provider connection closed"],
["catalog", "Provider closed"],
["session.configure", "Provider closed"],
["session.prompt", StaleProviderSessionError],
] as const)("contains connection shutdown while accepting %s", async (kind, error) => {
let disconnect!: () => Promise<void>;
await withObservedProvider(
{
async handleInput(input) {
if (input.type !== kind) return false;
await disconnect();
await nextTurn();
return true;
},
},
async (client, registry) => {
disconnect = () => registry.shutdown();
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
},
);
});
test.each([true, false])(
"preserves cancellation with acceptance pending: %s",
async (pending) => {
const controller = new AbortController();
const reason = new Error("Catalog refresh cancelled");
await withObservedProvider(
{
async handleInput(input) {
if (input.type !== "catalog") return false;
if (pending) {
controller.abort(reason);
await nextTurn();
} else {
setImmediate(() => controller.abort(reason));
}
return true;
},
},
async (client) => {
await expect(
client.fetchCatalog({ scope: "global" }, { signal: controller.signal }),
).rejects.toBe(reason);
},
);
},
);
});
@@ -92,6 +92,9 @@ function deferred<Value>(): Deferred<Value> {
resolve = onResolve;
reject = onReject;
});
// Provider events can reject before send() settles and the caller awaits this promise.
// Observe that interval without replacing the rejecting promise returned to the caller.
void promise.catch(() => undefined);
return { promise, resolve, reject };
}
@@ -51,6 +51,7 @@ describe("provider-owned option schemas", () => {
ClaudeProviderOptionsSchema.parse({
allowedTools: ["Read"],
disallowedTools: ["Bash(rm *)"],
extraArgs: { chrome: null, model: "x" },
sandbox: {
enabled: true,
failIfUnavailable: true,
@@ -63,7 +64,26 @@ describe("provider-owned option schemas", () => {
},
settings: { permissions: { ask: ["Bash(*)"], deny: ["Edit(.env)"] } },
}),
).toMatchObject({ sandbox: { enabled: true, failIfUnavailable: true } });
).toMatchObject({
extraArgs: { chrome: null, model: "x" },
sandbox: { enabled: true, failIfUnavailable: true },
});
});
test.each([true, 42, ["x"]])("rejects invalid Claude extra argument values: %j", (value) => {
expect(() =>
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
extraArgs: { chrome: value },
}),
).toThrow("providerOptions.extraArgs.chrome");
});
test("rejects a raw argument list for the SDK argument map", () => {
expect(() =>
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
extraArgs: ["--chrome"],
}),
).toThrow("providerOptions.extraArgs");
});
test("reports the exact invalid Claude option path", () => {
@@ -785,6 +785,29 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("passes extra Claude Code CLI arguments to the SDK", async () => {
const { queryFactory, launches } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
providerOptions: {
extraArgs: { chrome: null, model: "x" },
},
});
await expect(session.startTurn("hello")).resolves.toEqual({
turnId: expect.stringMatching(/^foreground-turn-/),
});
expect(launches[0]?.options.extraArgs).toEqual({ chrome: null, model: "x" });
await session.close();
});
test("lists fast mode only for supported Opus models", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
@@ -210,6 +210,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
env: {
ANTHROPIC_MODEL: "openrouter/anthropic/claude-sonnet-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ollama/qwen3-coder",
ANTHROPIC_DEFAULT_FABLE_MODEL: "claude-fable-5-1[1M]",
ANTHROPIC_DEFAULT_OPUS_MODEL: "bedrock-opus-from-env",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
@@ -244,6 +245,12 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
label: "ollama/qwen3-coder",
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
},
{
provider: "claude",
id: "claude-fable-5-1[1M]",
label: "claude-fable-5-1[1M]",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
},
{
provider: "claude",
id: "bedrock-opus-from-env",
@@ -263,6 +270,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
},
]);
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
});
it("falls back to hardcoded models when settings.json is missing", async () => {
@@ -14,6 +14,7 @@ import {
const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [
"ANTHROPIC_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
@@ -48,6 +48,7 @@ export const ClaudeProviderOptionsSchema = z
allowedTools: z.array(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
additionalDirectories: z.array(z.string()).optional(),
extraArgs: z.record(z.string(), z.string().nullable()).optional(),
sandbox: z
.object({
enabled: z.boolean().optional(),
@@ -376,6 +376,25 @@ describe("OMP agent client and session", () => {
expect(omp.completedTurnCount()).toBe(1);
});
test("does not complete a turn when a custom message arrives before its user message", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("hello OMP");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptCustomMessage("startup notice");
expect(omp.completedTurnCount()).toBe(0);
runtime.acceptPrompt("hello OMP", "user-1");
runtime.streamAssistantText("model turn completed");
runtime.finishTurn();
await waitForImmediate();
expect(omp.completedTurnCount()).toBe(1);
});
test("omits live custom messages when display is false", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -2033,9 +2033,6 @@ export class OmpAgentSession implements AgentSession {
});
}
}
if (!this.activeTurnHasUserMessage) {
this.completeTurn(turnId, []);
}
return;
}
@@ -2385,49 +2385,41 @@ describe("OpenCode adapter startTurn error handling", () => {
});
test("sends exact Hub MCP permission grants without approving unrelated tools", async () => {
const promptAsync = vi.fn(async () => ({ data: {}, error: undefined }));
const fakeClient = {
global: {
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
stream: {
async *[Symbol.asyncIterator](): AsyncGenerator<OpenCodeEvent> {
yield { type: "server.connected", properties: {} } as OpenCodeEvent;
await waitForAbort(signal);
},
},
})),
},
session: { promptAsync },
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/test",
providerOptions: { permission: { bash: "ask", hub_reply: "deny" } },
cwd,
providerOptions: { permission: { bash: "ask", question: "deny" } },
toolPolicy: {
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
},
},
fakeClient,
"ses_unit_test",
createTestLogger(),
);
await session.startTurn("finish");
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
permission: [
});
try {
const created = openCode.calls.sessionCreate[0] as {
permission?: Array<{ permission: string; pattern: string; action: string }>;
};
expect(created.permission).toEqual([
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "hub_reply", pattern: "*", action: "deny" },
],
}),
);
expect(promptAsync.mock.calls[0]?.[0].permission).not.toContainEqual(
expect.objectContaining({ permission: "bash", action: "allow" }),
);
await session.close();
{ permission: "question", pattern: "*", action: "deny" },
]);
expect(created.permission).not.toContainEqual(
expect.objectContaining({ permission: "bash", action: "allow" }),
);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("waits for the stop abort and provider idle before starting the next prompt", async () => {
@@ -6733,3 +6725,124 @@ describe("OpenCode snapshot summary false-idle regression", () => {
await session.close();
});
});
describe("OpenCode session permission rules", () => {
const logger = createTestLogger();
function permissionConfig(cwd: string): AgentSessionConfig {
return {
provider: "opencode",
cwd,
providerOptions: { permission: { bash: "ask", external_directory: "allow" } },
toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }] },
};
}
// OpenCode stores permission rules on the session (POST /session, PATCH /session/{id}) and
// drops an unknown `permission` field on prompt_async, so rules sent with a prompt never
// reach the server.
const expectedRules = [
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "external_directory", pattern: "*", action: "allow" },
];
test("creates the session with the agent's permission rules", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession(permissionConfig(cwd));
try {
expect(openCode.calls.sessionCreate[0]).toEqual(
expect.objectContaining({ permission: expectedRules }),
);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("applies the agent's permission rules to a resumed session", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.resumeSession(
{ provider: "opencode", sessionId: "ses_resumed_rules", metadata: { cwd } },
permissionConfig(cwd),
);
try {
expect(openCode.calls.sessionUpdate[0]).toEqual(
expect.objectContaining({
sessionID: "ses_resumed_rules",
permission: expectedRules,
}),
);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("fails the resume when the rules cannot be applied", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
openCode.sessionUpdateResponse = { error: { data: { message: "session is archived" } } };
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
// Resuming without the rules would run the agent under a more permissive policy than
// the one it was configured with, which is the failure this guards.
await expect(
client.resumeSession(
{ provider: "opencode", sessionId: "ses_archived", metadata: { cwd } },
permissionConfig(cwd),
),
).rejects.toThrow("Failed to apply OpenCode session permission rules");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("leaves a session without rules untouched", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.resumeSession({
provider: "opencode",
sessionId: "ses_resumed_no_rules",
metadata: { cwd },
});
try {
expect(openCode.calls.sessionUpdate).toEqual([]);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});
@@ -1452,12 +1452,22 @@ export class OpenCodeAgentClient implements AgentClient {
directory: openCodeConfig.cwd,
});
// OpenCode stores permission rules on the session, so they are set here and on resume
// rather than sent with each prompt, which drops them.
const permission = buildOpenCodePermissionRules(
openCodeConfig.providerOptions,
openCodeConfig.toolPolicy,
);
try {
// Creating the first session for a directory is part of OpenCode coming up, so it
// shares the server startup budget instead of a shorter one that fails agent
// creation on contended cold starts.
const response = await withTimeout(
client.session.create({ directory: openCodeConfig.cwd }),
client.session.create({
directory: openCodeConfig.cwd,
...(permission ? { permission } : {}),
}),
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
`OpenCode session.create timed out after ${Math.round(
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
@@ -1527,6 +1537,7 @@ export class OpenCodeAgentClient implements AgentClient {
});
try {
await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId);
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext);
@@ -1550,6 +1561,23 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
private async applySessionPermissionRules(
client: OpencodeClient,
config: OpenCodeAgentConfig,
sessionId: string,
): Promise<void> {
const permission = buildOpenCodePermissionRules(config.providerOptions, config.toolPolicy);
if (!permission) return;
const response = readOpenCodeRecord(
await client.session.update({ sessionID: sessionId, directory: config.cwd, permission }),
);
if (response?.error) {
throw new Error(
`Failed to apply OpenCode session permission rules: ${toDiagnosticErrorMessage(response.error)}`,
);
}
}
private acquireServer(
config: OpenCodeAgentConfig,
launchContext?: AgentLaunchContext,
@@ -3510,10 +3538,6 @@ class OpenCodeAgentSession implements AgentSession {
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const permission = buildOpenCodePermissionRules(
this.config.providerOptions,
this.config.toolPolicy,
);
const model = this.parseModel(this.config.model);
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
const effectiveVariant = this.config.thinkingOptionId ?? undefined;
@@ -3525,7 +3549,6 @@ class OpenCodeAgentSession implements AgentSession {
messageID: promptId,
parts,
...(systemPrompt ? { system: systemPrompt } : {}),
...(permission ? { permission } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
@@ -3850,10 +3873,6 @@ class OpenCodeAgentSession implements AgentSession {
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const permission = buildOpenCodePermissionRules(
this.config.providerOptions,
this.config.toolPolicy,
);
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
@@ -3868,7 +3887,6 @@ class OpenCodeAgentSession implements AgentSession {
}
: {}),
...(systemPrompt ? { system: systemPrompt } : {}),
...(permission ? { permission } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
@@ -11,6 +11,52 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeBridge } from "./opencode/bridge.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
test("real OpenCode server persists provider permissions across creation and resume", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
const cwd = path.join(root, "repo");
const logger = createTestLogger();
const manager = new OpenCodeServerManager({ logger, resolveHomeDir: () => root });
const client = new OpenCodeAgentClient(logger, undefined, { serverManager: manager });
let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
try {
await mkdir(cwd);
session = await client.createSession({
provider: "opencode",
cwd,
providerOptions: { permission: { external_directory: "allow" } },
});
inspection = await manager.acquireCurrent();
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: cwd });
async function readPermission(sessionId: string) {
const response = await sdk.session.get({ sessionID: sessionId, directory: cwd });
if (response.error) throw new Error(JSON.stringify(response.error));
return response.data?.permission;
}
const handle = session.describePersistence()!;
expect(await readPermission(handle.sessionId)).toEqual([
{ permission: "external_directory", pattern: "*", action: "allow" },
]);
await session.close();
// OpenCode appends session-update rules and the last matching rule wins, so a resume
// with a different policy leaves both entries in order.
session = await client.resumeSession(handle, {
providerOptions: { permission: { external_directory: "deny" } },
});
expect(await readPermission(handle.sessionId)).toEqual([
{ permission: "external_directory", pattern: "*", action: "allow" },
{ permission: "external_directory", pattern: "*", action: "deny" },
]);
} finally {
await inspection?.release();
await session?.close();
await manager.shutdown();
await rm(root, { recursive: true, force: true });
}
}, 120_000);
test("real OpenCode server shares one process while shell.env stays session-scoped", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-real-"));
const firstCwd = path.join(root, "first");
@@ -1,3 +1,4 @@
import { stat } from "node:fs/promises";
import { z } from "zod";
import { ensureValidJson } from "../../json-utils.js";
import type { Logger } from "pino";
@@ -223,6 +224,20 @@ function assertOptionsAbsent(
}
}
async function isExistingDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch (error) {
if (isMissingPathError(error)) return false;
throw error;
}
}
function isMissingPathError(error: unknown): boolean {
if (typeof error !== "object" || error === null || !("code" in error)) return false;
return error.code === "ENOENT" || error.code === "ENOTDIR";
}
function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): WorkspaceWorktreeTarget {
switch (input.mode ?? "branch-off") {
case "branch-off":
@@ -1225,7 +1240,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
path: z
.string()
.optional()
.describe("Local directory or source checkout. Defaults to your current workspace."),
.describe(
"Local directory or source checkout. Defaults to your current workspace. Local isolation adopts an existing directory and never creates one.",
),
projectId: z.string().optional().describe("Existing project id to own the workspace."),
title: z.string().trim().min(1).optional(),
mode: z
@@ -1277,6 +1294,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
let workspace: PersistedWorkspaceRecord;
if (isolation === "local") {
const cwd = resolveScopedCwd(path, { required: true });
if (!(await isExistingDirectory(cwd))) {
throw new Error(`Directory not found: ${cwd}`);
}
assertOptionsAbsent(
[
["mode", mode],
@@ -78,11 +78,15 @@ describe("SessionAuthorization", () => {
const authorization = new SessionAuthorization(["hub.execute"]);
for (const type of [
"create_agent_request",
"send_agent_message_request",
"workspace.title.set.request",
"fetch_agents_request",
"fetch_agent_request",
"agent.timeline.set_subscription.request",
"send_agent_message_request",
"workspace.recovery.inspect.request",
"workspace.recovery.restore.request",
"archive_workspace_request",
"cancel_agent_request",
] as const) {
expect(authorization.allowsInbound(inboundMessage(type))).toBe(true);
}
@@ -90,8 +94,17 @@ describe("SessionAuthorization", () => {
"status",
"agent_update",
"agent_stream",
"workspace_update",
"rpc_error",
"workspace.title.set.response",
"fetch_agents_response",
"fetch_agent_response",
"agent.timeline.set_subscription.response",
"send_agent_message_response",
"workspace.recovery.inspect.response",
"workspace.recovery.restore.response",
"archive_workspace_response",
"cancel_agent_response",
] as const) {
expect(authorization.allowsOutbound(outboundMessage(type))).toBe(true);
}
@@ -207,7 +207,7 @@ const INBOUND_PERMISSION = {
"workspace.script.list.request": "workspace.read",
"workspace.script.start.request": "workspace.write",
"workspace.script.stop.request": "workspace.write",
"workspace.title.set.request": "workspace.manage",
"workspace.title.set.request": ["workspace.manage", "hub.execute"],
workspace_setup_status_request: "workspace.read",
"workspace.setup.run.request": "workspace.write",
write_project_config_request: "workspace.write",
@@ -430,7 +430,7 @@ const OUTBOUND_PERMISSION = {
"workspace.script.list.response": "workspace.read",
"workspace.script.start.response": "workspace.write",
"workspace.script.stop.response": "workspace.write",
"workspace.title.set.response": "workspace.manage",
"workspace.title.set.response": ["workspace.manage", "hub.execute"],
workspace_setup_progress: "workspace.read",
workspace_setup_status_response: "workspace.read",
"workspace.setup.run.response": "workspace.write",
+2
View File
@@ -471,6 +471,7 @@ export interface PaseoDaemon {
start(): Promise<void>;
stop(): Promise<void>;
getListenTarget(): ListenTarget | null;
getServerId(): string;
}
export interface PaseoDaemonDependencies {
@@ -1831,6 +1832,7 @@ export async function createPaseoDaemon(
start,
stop,
getListenTarget: () => boundListenTarget,
getServerId: () => serverId,
};
}
+6 -1
View File
@@ -17,6 +17,7 @@ type SupervisorLifecycleMessage =
| {
type: "paseo:ready";
listen: string;
serverId: string;
}
| {
type: "paseo:restart";
@@ -327,7 +328,11 @@ async function main() {
if (!listen) {
throw new Error("Daemon did not expose a listen target after startup");
}
sendSupervisorLifecycleMessage({ type: "paseo:ready", listen });
sendSupervisorLifecycleMessage({
type: "paseo:ready",
listen,
serverId: daemon.getServerId(),
});
} catch (err) {
logger.fatal({ err }, "Daemon failed to start listening");
throw err;
@@ -1,6 +1,6 @@
import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { basename, dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -56,6 +56,49 @@ describe("file uploads", () => {
expect(readFileSync(path, "utf8")).toBe("hello world");
});
it("keeps the original file name for non-ASCII and punctuated names", async () => {
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
for (const fileName of [
"2026年9月绩效计划表.xlsx",
"테스트 파일 (1).xlsx",
"résumé [final] & notes, v2.pdf",
"cafe\u0301 हिंदी.txt",
]) {
const file = await uploadNamed(uploads, fileName);
expect(file?.fileName).toBe(fileName);
expect(basename(file!.path)).toBe(fileName);
expect(readFileSync(file!.path, "utf8")).toBe("hello world");
}
});
it("replaces path separators, control characters, and characters Windows rejects", async () => {
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
await expect(uploadNamed(uploads, "../../etc/passwd")).resolves.toMatchObject({
fileName: "passwd",
});
const backslashed = await uploadNamed(uploads, "dir\\name.txt");
expect(backslashed?.fileName).not.toContain("\\");
expect(backslashed?.fileName).toMatch(/name\.txt$/);
await expect(uploadNamed(uploads, 'a<b>:"c|?*.txt')).resolves.toMatchObject({
fileName: "a_b___c___.txt",
});
await expect(uploadNamed(uploads, "line\nbreak.txt")).resolves.toMatchObject({
fileName: "line_break.txt",
});
});
it("shortens a long non-ASCII name to the file system limit and keeps its extension", async () => {
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
const file = await uploadNamed(uploads, `${"绩".repeat(100)}.xlsx`);
expect(file?.fileName).toBe(`${"绩".repeat(83)}.xlsx`);
expect(Buffer.byteLength(file!.fileName)).toBeLessThanOrEqual(255);
expect(readFileSync(file!.path, "utf8")).toBe("hello world");
});
it("rejects chunks beyond the declared size and removes the partial file", async () => {
const paseoHome = makePaseoHome();
const uploads = new FileUploadStore({ paseoHome });
@@ -202,6 +245,25 @@ describe("file uploads", () => {
});
});
let uploadCount = 0;
async function uploadNamed(uploads: FileUploadStore, fileName: string) {
const requestId = `req-named-${uploadCount++}`;
uploads.beginUpload({
type: "file.upload.request",
fileName,
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId,
});
await uploads.receiveFrame(uploadBegins(requestId));
await uploads.receiveFrame(uploadChunk(requestId, "hello world"));
const response = await uploads.receiveFrame(uploadEnds(requestId));
expect(response?.payload.error).toBeNull();
return response?.payload.file;
}
function makePaseoHome(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "file-upload-test-")));
tempDirs.push(root);
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { basename, extname, join } from "node:path";
import { FileTransferOpcode, type FileTransferFrame } from "@getpaseo/protocol/binary-frames/index";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
@@ -220,9 +220,26 @@ function buildUploadResponse(upload: PendingUpload, error: string | null): FileU
};
}
// Most file systems cap a single file name at 255 bytes.
const MAX_FILE_NAME_BYTES = 255;
// Keeps the client's file name, replacing only what cannot appear in a single
// file name on Linux, macOS, or Windows.
function sanitizeFileName(value: string): string {
const name = basename(value)
.replace(/[^a-zA-Z0-9._ -]/g, "_")
.replace(/[\p{Cc}\\/:*?"<>|]/gu, "_")
.trim();
return name.length > 0 && name !== "." && name !== ".." ? name : "upload";
return fitFileNameLength(name.length > 0 && name !== "." && name !== ".." ? name : "upload");
}
function fitFileNameLength(name: string): string {
if (Buffer.byteLength(name) <= MAX_FILE_NAME_BYTES) return name;
const extension = extname(name);
const keptExtension = Buffer.byteLength(extension) < MAX_FILE_NAME_BYTES ? extension : "";
let stem = "";
for (const char of name.slice(0, name.length - keptExtension.length)) {
if (Buffer.byteLength(stem + char + keptExtension) > MAX_FILE_NAME_BYTES) break;
stem += char;
}
return stem + keptExtension;
}
@@ -1,4 +1,5 @@
import { afterEach, expect, test } from "vitest";
import type { SessionOutboundMessage } from "../messages.js";
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
let relationship: HubRelationshipHarness | null = null;
@@ -16,6 +17,13 @@ async function launchRelationship(): Promise<HubRelationshipHarness> {
return launched;
}
function createdAgentWorkspaceId(message: SessionOutboundMessage): string {
if (message.type !== "status" || message.payload.status !== "agent_created")
throw new Error("Agent was not created");
if (!message.payload.agent.workspaceId) throw new Error("Workspace was not created");
return message.payload.agent.workspaceId;
}
test("Hub retries one durable daemon execution across concurrency and reconstruction", async () => {
const hub = await launchRelationship();
@@ -54,6 +62,46 @@ test("Hub execute can steer ordinary agents while unrelated administration stays
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
});
test("Hub execute can title the workspace created with an agent", async () => {
const hub = await launchRelationship();
const created = await hub.requestOrdinary({
type: "create_agent_request",
requestId: "create-for-title",
config: { provider: "codex", cwd: hub.repoRoot() },
});
expect(created).toMatchObject({
type: "status",
payload: { status: "agent_created", agent: { workspaceId: expect.any(String) } },
});
const workspaceId = createdAgentWorkspaceId(created);
expect(
await hub.requestOrdinary({
type: "workspace.title.set.request",
requestId: "title-created-workspace",
workspaceId,
title: "Hub execution",
}),
).toMatchObject({
type: "workspace.title.set.response",
payload: { workspaceId, accepted: true, title: "Hub execution", error: null },
});
expect(
await hub.requestOrdinary({
type: "fetch_workspaces_request",
requestId: "read-created-workspace",
}),
).toMatchObject({
type: "fetch_workspaces_response",
payload: {
entries: expect.arrayContaining([
expect.objectContaining({ id: workspaceId, title: "Hub execution" }),
]),
},
});
});
test("ordinary Hub create and message retries do not duplicate agents or prompts", async () => {
const hub = await launchRelationship();
const create = {
@@ -6,6 +6,7 @@ import { describe, expect, test } from "vitest";
import {
loadPersistedConfig,
PersistedConfigSchema,
readPersistedConfig,
savePersistedConfig,
} from "./persisted-config.js";
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js";
@@ -736,6 +737,62 @@ describe("loadPersistedConfig", () => {
});
});
describe("config.json saved with a UTF-8 byte order mark", () => {
// Windows Notepad writes this shape: a BOM, then CRLF line endings.
const notepadConfig =
'{\r\n "version": 1,\r\n "daemon": { "listen": "127.0.0.1:6767" }\r\n}\r\n';
test("loadPersistedConfig reads it", () => {
const home = createTempHome();
try {
writeFileSync(path.join(home, "config.json"), notepadConfig);
expect(loadPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("readPersistedConfig reads it", () => {
const home = createTempHome();
try {
writeFileSync(path.join(home, "config.json"), notepadConfig);
expect(readPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe("readPersistedConfig with an unreadable config.json", () => {
test("names the file when it is not valid JSON", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(configPath, '{"version":1,');
expect(() => readPersistedConfig(home)).toThrow(`[Config] Invalid JSON in ${configPath}: `);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("names the file and the field when it does not match the schema", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(configPath, '{"daemon":{"listen":5}}');
expect(() => readPersistedConfig(home)).toThrow(
`[Config] Invalid config in ${configPath}:\n - daemon.listen: `,
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
test("initializes config.json with private permissions", () => {
const home = createTempHome();
+31 -22
View File
@@ -436,27 +436,9 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
});
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, {
cause: err,
});
}
const migrated = stripRemovedConfigFields(parsed);
const result = PersistedConfigSchema.safeParse(migrated);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`);
}
const config = parseConfigFile(configPath, raw);
log?.info(`Loaded from ${configPath}`);
return result.data as PersistedConfig;
return config;
}
/** Observe the file without initializing a home, identity, or default configuration. */
@@ -464,15 +446,42 @@ export function readPersistedConfig(
paseoHome: string,
options: { defaultsIfMissing?: boolean } = {},
): PersistedConfig {
const configPath = getConfigPath(paseoHome);
let raw: string;
try {
raw = readFileSync(getConfigPath(paseoHome), "utf8");
raw = readFileSync(configPath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT")
return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {};
throw error;
}
return PersistedConfigSchema.parse(stripRemovedConfigFields(JSON.parse(raw))) as PersistedConfig;
return parseConfigFile(configPath, raw);
}
function parseConfigFile(configPath: string, raw: string): PersistedConfig {
let parsed: unknown;
try {
parsed = parseConfigText(raw);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, {
cause: err,
});
}
const result = PersistedConfigSchema.safeParse(stripRemovedConfigFields(parsed));
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`);
}
return result.data as PersistedConfig;
}
/** Editors such as Windows Notepad save UTF-8 with a byte order mark, which JSON.parse rejects. */
function parseConfigText(raw: string): unknown {
return JSON.parse(raw.replace(/^\uFEFF/, ""));
}
function configPathParts(field: string): string[] {
+35 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, open, rm, stat, utimes, writeFile } from "node:fs/promises";
import { mkdtemp, open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
@@ -256,4 +256,38 @@ describe("pid-lock ownership", () => {
await rm(paseoHome, { recursive: true, force: true });
}
});
test("starts over an empty lock file left by a supervisor killed before writing it", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-empty-"));
const ownerPid = process.pid + 10_000;
try {
await writeFile(join(paseoHome, "paseo.pid"), "");
await expect(getPidLockInfo(paseoHome)).resolves.toBeNull();
await acquirePidLock(paseoHome, null, { ownerPid });
const lock = await getPidLockInfo(paseoHome);
expect(lock?.pid).toBe(ownerPid);
} finally {
await rm(paseoHome, { recursive: true, force: true });
}
});
test("keeps a lock file whose contents cannot be read as a lock", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-unparseable-"));
const pidPath = join(paseoHome, "paseo.pid");
try {
await writeFile(pidPath, JSON.stringify({ pid: "unknown" }));
await expect(
acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }),
).rejects.toThrow("Cannot read daemon state");
await expect(readFile(pidPath, "utf-8")).resolves.toBe(JSON.stringify({ pid: "unknown" }));
} finally {
await rm(paseoHome, { recursive: true, force: true });
}
});
});
+24 -5
View File
@@ -1,4 +1,4 @@
import { open, readFile, unlink, utimes } from "node:fs/promises";
import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { ensurePrivateDirectory } from "./private-files.js";
import { join } from "node:path";
@@ -11,6 +11,7 @@ export const pidLockInfoSchema = z.object({
hostname: z.string(),
uid: z.number(),
listen: z.string().nullable(),
serverId: z.string().nullable().optional(),
desktopManaged: z.boolean().optional(),
heartbeat: z.literal(true).optional(),
});
@@ -84,18 +85,26 @@ async function touchPidLockFile(pidPath: string): Promise<void> {
async function readPidLock(pidPath: string): Promise<PidLockInfo | null> {
let lastError: unknown;
let empty = false;
for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) {
try {
const content = await readFile(pidPath, "utf-8");
const lock = parsePidLockInfo(JSON.parse(content));
if (lock) return lock;
lastError = new Error("Invalid lock shape");
empty = content === "";
if (!empty) {
const lock = parsePidLockInfo(JSON.parse(content));
if (lock) return lock;
lastError = new Error("Invalid lock shape");
}
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") return null;
empty = false;
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS));
}
// A supervisor writes its lock right after creating the file, so a file still empty
// after the retries was abandoned in between and names no owner.
if (empty) return null;
throw Object.assign(
new PidLockError(`Cannot read daemon state at ${pidPath}: ${String(lastError)}`),
{ code: "DAEMON_STATE_READ_FAILED" },
@@ -149,6 +158,14 @@ async function clearExistingPidLock(
return "cleared";
}
async function removeEmptyPidLock(pidPath: string): Promise<void> {
try {
if ((await stat(pidPath)).size === 0) await unlink(pidPath);
} catch (error) {
if (!isErrnoException(error) || error.code !== "ENOENT") throw error;
}
}
async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise<void> {
let fd;
try {
@@ -191,6 +208,8 @@ export async function acquirePidLock(
if (result === "already_owned") {
return;
}
} else {
await removeEmptyPidLock(pidPath);
}
// Create new lock with exclusive flag
@@ -301,7 +320,7 @@ export function startPidLockHeartbeat(
export async function updatePidLock(
paseoHome: string,
patch: { listen: string | null },
patch: { listen: string; serverId: string } | { listen: null; serverId: null },
options?: { ownerPid?: number },
): Promise<void> {
const pidPath = getPidFilePath(paseoHome);
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -457,3 +458,80 @@ test("a provider that never acknowledges session.close does not hold the daemon
// test fails by timing out.
await expect(daemon.daemon.stop()).resolves.toBeUndefined();
}, 90_000);
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
// Hold the provider close until the subprocess has entered shutdown cleanup.
async function createSlowClosingProviderPlugin() {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-slow-close-plugin-"));
const closeStarted = path.join(directory, "close-started");
const closeRelease = path.join(directory, "close-release");
const cleanupStarted = path.join(directory, "cleanup-started");
await cp(path.join(repoRoot, "plugin-examples/provider-direct"), directory, { recursive: true });
const providerPath = path.join(directory, "server", "provider.ts");
const source = (await readFile(providerPath, "utf8")).replaceAll("\r\n", "\n");
const patched = `import { readFile, writeFile } from "node:fs/promises";\n${source.replace(
" closed = true;\n sessions.clear();",
` closed = true;
await writeFile(${JSON.stringify(closeStarted)}, "started");
while (true) {
try { await readFile(${JSON.stringify(closeRelease)}); break; }
catch { await new Promise((resolve) => setTimeout(resolve, 10)); }
}
sessions.clear();
console.log("provider close completed");`,
)}`;
expect(patched).toContain(`await writeFile(${JSON.stringify(closeStarted)}, "started")`);
await writeFile(providerPath, patched);
const entryPath = path.join(directory, "index.server.ts");
const entry = (await readFile(entryPath, "utf8")).replaceAll("\r\n", "\n");
const withCleanup = `import { writeFile } from "node:fs/promises";\n${entry.replace(
" return () => {};",
` return async () => { await writeFile(${JSON.stringify(cleanupStarted)}, "started"); console.log("plugin cleanup completed"); };`,
)}`;
expect(withCleanup).toContain(`await writeFile(${JSON.stringify(cleanupStarted)}, "started")`);
await writeFile(entryPath, withCleanup);
return { directory, closeStarted, closeRelease, cleanupStarted };
}
test("reloading a plugin does not send on a closed IPC channel", async () => {
const {
directory: pluginDirectory,
closeStarted,
closeRelease,
cleanupStarted,
} = await createSlowClosingProviderPlugin();
const cwd = await mkdtemp(path.join(tmpdir(), "paseo-reload-teardown-"));
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pluginDirectory);
const agent = await client.createAgent({ provider: "direct-example", cwd });
const turn = client.sendMessage(agent.id, "Say hello").catch(() => undefined);
const reload = client.reloadPlugin("provider-direct-example");
await expect
.poll(() => readFile(closeStarted, "utf8").catch(() => null), { timeout: 20_000 })
.toBe("started");
await expect
.poll(() => readFile(cleanupStarted, "utf8").catch(() => null), { timeout: 20_000 })
.toBe("started");
await writeFile(closeRelease, "release");
const reloaded = await reload;
expect(reloaded.status).toBe("running");
const entries = await client.getPluginLogs("provider-direct-example");
const messages = entries.map((entry) => entry.message).join("\n");
expect(messages).not.toContain("ERR_IPC_CHANNEL_CLOSED");
expect(entries.filter((entry) => entry.message === "plugin cleanup completed")).toHaveLength(1);
expect(entries.filter((entry) => entry.message === "provider close completed")).toHaveLength(1);
await turn;
await client.archiveAgent(agent.id).catch(() => undefined);
} finally {
await writeFile(closeRelease, "release");
await client.close();
await daemon.close();
await rm(cwd, { recursive: true, force: true });
await rm(pluginDirectory, { recursive: true, force: true });
}
}, 60_000);
@@ -51,7 +51,7 @@ const handlers = new Map<string, RegisteredRpc>();
const providers = new Map<string, ProviderRegistration>();
const providerConnections = new Map<
string,
{ connection: ProviderConnection; unsubscribe: () => void }
{ connection: ProviderConnection; unsubscribe: () => void; closing?: Promise<void> }
>();
const pendingProviderConnections = new Map<string, { tombstoned: boolean }>();
let cleanup: (() => void | Promise<void>) | null = null;
@@ -193,6 +193,7 @@ async function sendProviderInput(
if (stopping) throw new Error("Plugin is stopping");
const current = providerConnections.get(message.connectionId);
if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`);
if (current.closing) throw new Error("Provider connection is closing");
await current.connection.send(message.input);
send({
type: "provider.accepted",
@@ -201,13 +202,25 @@ async function sendProviderInput(
});
}
// The connection stays registered until its close has reported, so shutdown
// waits for a close already in flight instead of disconnecting underneath it.
async function closeProviderConnection(connectionId: string): Promise<void> {
const current = providerConnections.get(connectionId);
if (!current) return;
providerConnections.delete(connectionId);
current.unsubscribe();
await current.connection.close();
send({ type: "provider.closed", connectionId });
if (current.closing) return current.closing;
const closing = (async () => {
current.unsubscribe();
try {
await current.connection.close();
send({ type: "provider.closed", connectionId });
} catch (error) {
send({ type: "provider.closed", connectionId, error: describeError(error) });
} finally {
providerConnections.delete(connectionId);
}
})();
current.closing = closing;
return closing;
}
function runtimeRequire(name: string): unknown {
@@ -397,13 +410,7 @@ process.on("message", (rawMessage: unknown) => {
return;
}
if (message.type === "provider.close") {
void closeProviderConnection(message.connectionId).catch((error) => {
send({
type: "provider.closed",
connectionId: message.connectionId,
error: describeError(error),
});
});
void closeProviderConnection(message.connectionId);
return;
}
if (message.type === "paseo_frame" || message.type === "paseo_close") return;
@@ -34,6 +34,26 @@ async function readTextIfPresent(filePath: string): Promise<string | null> {
}
}
function pluginMessages(runtime: PluginRuntime, pluginId: string): string[] {
return runtime.getLogs(pluginId).map((entry) => entry.message);
}
function spawnObservedPluginProcess(onMessage: (message: unknown) => void) {
const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href;
const setup = `import { register } from "node:module"; register(${JSON.stringify(loaderUrl)});`;
const child = fork(new URL("./plugin-process.ts", import.meta.url), [], {
execArgv: [
"--experimental-strip-types",
"--import",
`data:text/javascript,${encodeURIComponent(setup)}`,
],
serialization: "advanced",
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
child.on("message", onMessage);
return child;
}
function createReloadChild(
name: string,
events: string[],
@@ -1040,6 +1060,75 @@ export default function contribute(plugin: unknown) {
await rm(cleanupFile, { force: true });
});
it("rejects provider input while a connection is closing", async () => {
const releaseDirectory = await mkdtemp(path.join(tmpdir(), "paseo-provider-close-"));
const releaseFile = path.join(releaseDirectory, "release");
const directory = await createPlugin(
"closing-provider",
`import { readFile, writeFile } from "node:fs/promises";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
const provider: ProviderRegistration = {
id: "delayed",
label: "Delayed",
async connect() {
return {
version: 1,
capabilities: [],
async send() { console.log("provider accepted input"); },
onEvent() { return () => undefined; },
async close() {
console.log("provider close started");
while (true) {
try { await readFile(${JSON.stringify(releaseFile)}); break; }
catch { await new Promise((resolve) => setTimeout(resolve, 10)); }
}
console.log("provider close completed");
},
};
},
};
export default function contribute(server: { registerProvider(provider: ProviderRegistration): void }) {
server.registerProvider(provider);
return () => console.log("plugin cleanup started");
}`,
);
const closedReports: unknown[] = [];
const observeMessage = (message: unknown) => {
if ((message as { type?: string }).type === "provider.closed") closedReports.push(message);
};
const runtime = createTestRuntime({
spawnChild: () => spawnObservedPluginProcess(observeMessage),
});
await runtime.startPlugin("closing-provider", directory);
try {
const connection = await runtime.connectProvider("closing-provider", "delayed", {
versions: [1],
capabilities: [],
});
const closing = connection.close();
await expect
.poll(() => pluginMessages(runtime, "closing-provider"))
.toContain("provider close started");
await expect(connection.send({ type: "catalog", requestId: "during-close" })).rejects.toThrow(
"Provider connection is closing",
);
const stopping = runtime.stopPluginById("closing-provider");
await expect
.poll(() => pluginMessages(runtime, "closing-provider"))
.toContain("plugin cleanup started");
await writeFile(releaseFile, "release");
await Promise.all([closing, stopping]);
const messages = pluginMessages(runtime, "closing-provider");
expect(messages).toContain("provider close completed");
expect(messages).not.toContain("provider accepted input");
expect(closedReports).toHaveLength(1);
} finally {
await writeFile(releaseFile, "release");
await runtime.stopAll();
await rm(releaseDirectory, { recursive: true, force: true });
}
});
it("closes a provider connection that resolves during plugin shutdown", async () => {
const suffix = `${process.pid}-${Date.now()}`;
const startedFile = path.join(tmpdir(), `paseo-provider-connect-started-${suffix}`);
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -803,7 +803,7 @@ describe("ScheduleService", () => {
const runStarted = new Promise<void>((resolve) => {
releaseRun = resolve;
});
const store = new ScheduleStore(join(tempDir, "schedules"));
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
const legacy = await store.create({
name: null,
prompt: "finish/update race",
@@ -1902,6 +1902,51 @@ describe("ScheduleService", () => {
await service2.stop();
});
test("starts with the valid schedules when the schedules directory holds files that are not schedules", async () => {
const service1 = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const created = await service1.create({
prompt: "Still scheduled",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
runOnCreate: false,
});
await service1.stop();
const schedulesDir = join(tempDir, "schedules");
await writeFile(join(schedulesDir, "notes.json"), JSON.stringify({ hello: "world" }));
const { lastRunAt: _omitted, ...withoutLastRunAt } = created;
await writeFile(
join(schedulesDir, "deadbeef.json"),
JSON.stringify({ ...withoutLastRunAt, id: "deadbeef" }),
);
await writeFile(join(schedulesDir, "broken.json"), "{ not json");
const service2 = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
await service2.start();
expect((await service2.list()).map((schedule) => schedule.id)).toEqual([created.id]);
await service2.stop();
});
test("startup recovery archives an interrupted run workspace with an associated agent", async () => {
const service1 = createScheduleService({
paseoHome: tempDir,
@@ -1926,7 +1971,7 @@ describe("ScheduleService", () => {
const interruptedAt = now.toISOString();
const associatedAgentId = "11111111-1111-4111-8111-111111111111";
const workspaceId = "wks_interrupted_with_agent";
const store = new ScheduleStore(join(tempDir, "schedules"));
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
await store.update(created.id, (schedule) => ({
...schedule,
runs: [
@@ -1994,7 +2039,7 @@ describe("ScheduleService", () => {
const interruptedAt = now.toISOString();
const workspaceId = "wks_interrupted_without_agent";
const store = new ScheduleStore(join(tempDir, "schedules"));
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
await store.update(created.id, (schedule) => ({
...schedule,
runs: [
@@ -264,8 +264,8 @@ export class ScheduleService {
private tickTimer: ReturnType<typeof setInterval> | null = null;
constructor(options: ScheduleServiceOptions) {
this.store = new ScheduleStore(join(options.paseoHome, "schedules"));
this.logger = options.logger.child({ module: "schedule-service" });
this.store = new ScheduleStore(join(options.paseoHome, "schedules"), this.logger);
this.agentManager = options.agentManager;
this.agentStorage = options.agentStorage;
this.createAgent = options.createAgent;
@@ -1,7 +1,10 @@
import { mkdtemp, rm } from "node:fs/promises";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Writable } from "node:stream";
import pino from "pino";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { ScheduleStore } from "./store.js";
describe("ScheduleStore", () => {
@@ -10,7 +13,7 @@ describe("ScheduleStore", () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "schedule-store-test-"));
store = new ScheduleStore(tempDir);
store = new ScheduleStore(tempDir, createTestLogger());
});
afterEach(async () => {
@@ -40,13 +43,66 @@ describe("ScheduleStore", () => {
runs: [],
});
const reloaded = new ScheduleStore(tempDir);
const reloaded = new ScheduleStore(tempDir, createTestLogger());
const listed = await reloaded.list();
expect(created.id).toHaveLength(8);
expect(listed).toEqual([created]);
});
test("reports an invalid schedule file once while it stays invalid, by name, and lists the rest", async () => {
const created = await store.create({
name: "Morning summary",
prompt: "Summarize new commits",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
status: "active",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: "2026-01-01T00:01:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
});
await writeFile(join(tempDir, "notes.json"), JSON.stringify({ hello: "world" }));
const logLines: Array<{ msg: string; filePath?: string }> = [];
const logger = pino(
{ level: "error" },
new Writable({
write(chunk, _encoding, callback) {
logLines.push(JSON.parse(chunk.toString("utf8")));
callback();
},
}),
);
const reloaded = new ScheduleStore(tempDir, logger);
expect(await reloaded.list()).toEqual([created]);
expect(await reloaded.list()).toEqual([created]);
await rm(join(tempDir, "notes.json"));
expect(await reloaded.list()).toEqual([created]);
await writeFile(join(tempDir, "notes.json"), "{ not json");
expect(await reloaded.list()).toEqual([created]);
const skipped = {
msg: "Skipping invalid schedule file",
filePath: join(tempDir, "notes.json"),
};
expect(logLines.map(({ msg, filePath }) => ({ msg, filePath }))).toEqual([skipped, skipped]);
});
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"fails the listing when a schedule file cannot be read",
async () => {
await writeFile(join(tempDir, "unreadable.json"), "{}");
await chmod(join(tempDir, "unreadable.json"), 0o000);
await expect(store.list()).rejects.toMatchObject({ code: "EACCES" });
},
);
test("update round-trips an updated schedule to disk", async () => {
const created = await store.create({
name: "before",
@@ -81,7 +137,7 @@ describe("ScheduleStore", () => {
};
await store.update(created.id, () => updated);
const reloaded = await new ScheduleStore(tempDir).get(created.id);
const reloaded = await new ScheduleStore(tempDir, createTestLogger()).get(created.id);
expect(reloaded).toEqual(updated);
});
@@ -182,7 +238,9 @@ describe("ScheduleStore", () => {
prompt: "after",
runs: [{ id: "run-1" }],
});
await expect(new ScheduleStore(tempDir).get(created.id)).resolves.toMatchObject({
await expect(
new ScheduleStore(tempDir, createTestLogger()).get(created.id),
).resolves.toMatchObject({
prompt: "after",
runs: [{ id: "run-1" }],
});
@@ -211,7 +269,7 @@ describe("ScheduleStore", () => {
}
}
const gatedStore = new GatedListScheduleStore(tempDir);
const gatedStore = new GatedListScheduleStore(tempDir, createTestLogger());
const target = {
type: "new-agent" as const,
config: { provider: "claude" as const, cwd: tempDir },
+36 -4
View File
@@ -1,6 +1,7 @@
import { randomBytes } from "node:crypto";
import { mkdir, readFile, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import type { Logger } from "pino";
import {
StoredScheduleSchema,
type ScheduleTarget,
@@ -87,11 +88,27 @@ function matchesNameAndTarget(
);
}
function parseStoredSchedule(
content: string,
): { success: true; data: StoredSchedule } | { success: false; error: unknown } {
let json: unknown;
try {
json = JSON.parse(content);
} catch (error) {
return { success: false, error };
}
return StoredScheduleSchema.safeParse(json);
}
export class ScheduleStore {
private readonly scheduleMutations = new Map<string, Promise<unknown>>();
private readonly identityMutations = new Map<string, Promise<unknown>>();
private reportedInvalidFiles = new Set<string>();
constructor(private readonly dir: string) {}
constructor(
private readonly dir: string,
private readonly logger: Logger,
) {}
private filePath(id: string): string {
return join(this.dir, `${id}.json`);
@@ -101,17 +118,32 @@ export class ScheduleStore {
await mkdir(this.dir, { recursive: true });
}
// The service lists schedules on every tick, so a file that is not a valid schedule is
// reported when it first appears rather than once per second.
async list(): Promise<StoredSchedule[]> {
await this.ensureDir();
const entries = await readdir(this.dir, { withFileTypes: true });
const schedules = await Promise.all(
const files = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map(async (entry) => {
const content = await readFile(join(this.dir, entry.name), "utf-8");
return StoredScheduleSchema.parse(JSON.parse(content));
const filePath = join(this.dir, entry.name);
return { filePath, parsed: parseStoredSchedule(await readFile(filePath, "utf-8")) };
}),
);
const schedules: StoredSchedule[] = [];
const invalidFiles = new Set<string>();
for (const { filePath, parsed } of files) {
if (parsed.success) {
schedules.push(parsed.data);
continue;
}
invalidFiles.add(filePath);
if (!this.reportedInvalidFiles.has(filePath)) {
this.logger.error({ err: parsed.error, filePath }, "Skipping invalid schedule file");
}
}
this.reportedInvalidFiles = invalidFiles;
return schedules.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
@@ -85,6 +85,43 @@ function headBranch(dir: string): string {
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir }).toString().trim();
}
// A clone whose local branch tracks origin/main. Upstream inheritance only shows up
// against a real remote, so these fixtures cross the git boundary like the happy paths above.
function initClonedRepo(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "git-mutation-clone-")));
tempRepos.push(root);
const remote = join(root, "remote.git");
const seed = join(root, "seed");
const work = join(root, "work");
function run(cwd: string, ...args: string[]): void {
execFileSync("git", args, { cwd, stdio: "pipe" });
}
execFileSync("git", ["init", "--bare", "-b", "main", remote], { cwd: root, stdio: "pipe" });
execFileSync("git", ["clone", "--quiet", remote, seed], { cwd: root, stdio: "pipe" });
run(seed, "config", "user.email", "test@example.com");
run(seed, "config", "user.name", "Paseo Test");
writeFileSync(join(seed, "README.md"), "hello\n");
run(seed, "add", "-A");
run(seed, "commit", "-m", "init");
run(seed, "push", "--quiet", "-u", "origin", "main");
execFileSync("git", ["clone", "--quiet", remote, work], { cwd: root, stdio: "pipe" });
run(work, "config", "user.email", "test@example.com");
run(work, "config", "user.name", "Paseo Test");
return work;
}
// Always exits 0 and prints an empty line when the branch has no upstream, so a git or
// process failure surfaces as a throw instead of being read as "no upstream".
function upstreamOf(dir: string, branch: string): string {
return execFileSync(
"git",
["for-each-ref", "--format=%(upstream:short)", `refs/heads/${branch}`],
{ cwd: dir, stdio: ["pipe", "pipe", "pipe"] },
)
.toString()
.trim();
}
afterEach(() => {
while (tempRepos.length > 0) {
const dir = tempRepos.pop();
@@ -189,6 +226,40 @@ describe("createBranchFromBase", () => {
expect(invalidateCalls).toEqual([]);
expect(snapshotCalls).toContainEqual({ cwd: dir, force: true, reason: "create-branch" });
});
// https://github.com/getpaseo/paseo/issues/5213 — a branch that tracks origin/main sends the
// next push to main: `git push` under push.default=tracking and Paseo's own push both read
// branch.<name>.merge. A branch Paseo creates must carry no upstream, exactly as the worktree
// path already guarantees with `git worktree add -b <branch> --no-track <base>`.
test("leaves no upstream when the base is a remote-tracking ref (real repo)", async () => {
const dir = initClonedRepo();
const { service } = buildService({
resolution: { kind: "remote-only", name: "main", remoteRef: "origin/main" },
});
await service.createBranchFromBase({
cwd: dir,
baseBranch: "origin/main",
newBranchName: "feature-from-remote",
});
expect(headBranch(dir)).toBe("feature-from-remote");
expect(upstreamOf(dir, "feature-from-remote")).toBe("");
});
test("leaves no upstream when git is configured to inherit tracking (real repo)", async () => {
const dir = initClonedRepo();
execFileSync("git", ["config", "branch.autoSetupMerge", "inherit"], {
cwd: dir,
stdio: "pipe",
});
const { service } = buildService({ resolution: { kind: "local", name: "main" } });
await service.createBranchFromBase({
cwd: dir,
baseBranch: "main",
newBranchName: "feature-inherited",
});
expect(headBranch(dir)).toBe("feature-inherited");
expect(upstreamOf(dir, "feature-inherited")).toBe("");
});
});
describe("notifyGitMutation", () => {
@@ -118,7 +118,12 @@ export function createGitMutationService(deps: {
}
await ensureCleanWorkingTree(cwd);
await runGitCommand(["checkout", "-b", newBranchName, baseBranch], {
// --no-track: a branch we create is not a continuation of its base, so it gets no
// upstream. Without it git hands the new branch `origin/<base>` whenever the base is a
// remote-tracking ref or branch.autoSetupMerge asks for inheritance, and every later
// push then reads that upstream and lands the work on the default branch. The worktree
// path makes the same promise with `git worktree add -b <branch> --no-track <base>`.
await runGitCommand(["checkout", "-b", newBranchName, "--no-track", baseBranch], {
cwd,
timeout: 120_000,
});
@@ -996,27 +996,44 @@ describe("WorkspaceGitService checkout observation", () => {
test("origin/main refreshes a main checkout without configured upstream", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "main",
remoteUrl: REMOTE_URL,
resolvedBaseRef: "main",
comparisonBaseRef: null,
branchRemoteName: null,
branchMergeRef: null,
upstreamStatus: null,
const releaseInitialFacts = createDeferred<void>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => {
await releaseInitialFacts.promise;
return {
...createCheckoutFacts(cwd),
currentBranch: "main",
remoteUrl: REMOTE_URL,
resolvedBaseRef: "main",
comparisonBaseRef: null,
branchRemoteName: null,
branchMergeRef: null,
upstreamStatus: null,
};
});
const runGitFetch = vi.fn(async () => ({
changes: [],
nonRemoteRefsChanged: false,
error: null,
}));
const service = createService(watcher, { getCheckoutSnapshotFacts });
const service = createService(watcher, { getCheckoutSnapshotFacts, runGitFetch });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
]);
releaseInitialFacts.resolve();
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().fetchInFlightCount).toBe(0);
expect(runGitFetch).toHaveBeenCalledTimes(1);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
const repoWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0]!;
repoWatcher.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
@@ -1539,6 +1556,87 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("degraded polling backs off while the snapshot is unchanged and resets on a change", async () => {
// A repository large enough to defeat the recursive watcher makes every degraded refresh
// expensive, so a fixed cadence keeps the daemon shelling out Git on an untouched workspace.
const watcher = createWatcherHarness({ failDirectories: new Set([REPO_CWD]) });
let isDirty = false;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
const service = createService(watcher, { getCheckoutStatus });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
});
// Quiet ticks at 5s, 10s, 20s, 40s, then the 60s ceiling.
for (const [elapsedMs, expectedCalls] of [
[5_000, 2],
[10_000, 3],
[20_000, 4],
[40_000, 5],
[60_000, 6],
[60_000, 7],
] as const) {
await vi.advanceTimersByTimeAsync(elapsedMs);
expect(getCheckoutStatus).toHaveBeenCalledTimes(expectedCalls);
}
// A fixed 5s cadence would have run 39 polls over the same 195s.
expect(getCheckoutStatus.mock.calls.length).toBeLessThan(10);
// A real change snaps the loop back to the base interval.
isDirty = true;
await vi.advanceTimersByTimeAsync(60_000);
expect(getCheckoutStatus).toHaveBeenCalledTimes(8);
await vi.advanceTimersByTimeAsync(5_000);
expect(getCheckoutStatus).toHaveBeenCalledTimes(9);
subscription.unsubscribe();
service.dispose();
});
test("degraded repository-metadata polling backs off and resets on a change", async () => {
// Only the Git-directory watcher fails, so the metadata fallback is the one poll loop running.
const watcher = createWatcherHarness({ failDirectories: new Set([GIT_DIR]) });
let isDirty = false;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
const service = createService(watcher, { getCheckoutStatus });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
const callsBeforePolling = getCheckoutStatus.mock.calls.length;
function pollCount(): number {
return getCheckoutStatus.mock.calls.length - callsBeforePolling;
}
// Quiet ticks at 5s, 10s, 20s, 40s, then the 60s ceiling.
for (const [elapsedMs, expectedPolls] of [
[5_000, 1],
[10_000, 2],
[20_000, 3],
[40_000, 4],
[60_000, 5],
[60_000, 6],
] as const) {
await vi.advanceTimersByTimeAsync(elapsedMs);
expect(pollCount()).toBe(expectedPolls);
}
isDirty = true;
await vi.advanceTimersByTimeAsync(60_000);
expect(pollCount()).toBe(7);
await vi.advanceTimersByTimeAsync(5_000);
expect(pollCount()).toBe(8);
subscription.unsubscribe();
service.dispose();
});
test("non-Git fallback promotes an externally initialized checkout", async () => {
const watcher = createWatcherHarness();
let isGit = false;
@@ -1752,17 +1850,17 @@ describe("WorkspaceGitService checkout observation", () => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const statusCallsAfterSetup = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => {
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterSetup);
});
await vi.advanceTimersByTimeAsync(24_000);
await vi.advanceTimersByTimeAsync(29_000);
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(2);
});
expect(erroredUnsubscribe).toHaveBeenCalledTimes(1);
// Recovery schedules a debounced refresh. Advance the fake clock past the debounce
// explicitly; vi.waitFor alone runs out of real time before it on a slow runner.
await vi.advanceTimersByTimeAsync(2_000);
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterSetup);
subscription.unsubscribe();
service.dispose();
@@ -86,6 +86,13 @@ const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
const DEGRADED_GIT_POLL_INTERVAL_MS = 5_000;
// Degraded polling shells out a full Git refresh per tick, and the repositories that defeat the
// recursive watcher are the ones where that refresh is expensive. At a fixed cadence the daemon
// therefore burns a core forever on a workspace nobody has touched. Double the gap after every
// tick that produced an identical snapshot, and drop back to the base interval as soon as one
// does not. Explicit reads still force a refresh, so this only bounds background discovery of
// changes made outside Paseo.
const DEGRADED_GIT_POLL_MAX_INTERVAL_MS = 60_000;
// A dependency install creates directories for minutes. Collapse the burst into
// a couple of `git ls-files` runs rather than one per event.
const WORKING_TREE_IGNORE_REFRESH_QUIET_MS = 300;
@@ -442,6 +449,7 @@ interface RepoGitTarget {
subscription: FileObserverSubscription | null;
fallbackPolling: boolean;
fallbackPollTimer: NodeJS.Timeout | null;
fallbackPollQuietTickCount: number;
recovery: WatchRecoveryState;
intervalId: NodeJS.Timeout | null;
fetchInFlight: boolean;
@@ -501,6 +509,7 @@ interface WorkingTreeWatchTarget {
workspaceKeys: Set<string>;
fallbackPolling: boolean;
fallbackPollTimer: NodeJS.Timeout | null;
fallbackPollQuietTickCount: number;
recovery: WatchRecoveryState;
listeners: Set<() => void>;
closed: boolean;
@@ -1352,6 +1361,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
workspaceKeys: new Set(),
fallbackPolling: false,
fallbackPollTimer: null,
fallbackPollQuietTickCount: 0,
recovery: { attemptCount: 0, timer: null, establishedAt: null },
listeners: new Set(),
closed: false,
@@ -1558,6 +1568,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
}
private degradedPollDelayMs(quietTickCount: number): number {
return Math.min(
DEGRADED_GIT_POLL_MAX_INTERVAL_MS,
DEGRADED_GIT_POLL_INTERVAL_MS * 2 ** quietTickCount,
);
}
// A refresh that throws is swallowed by refreshWorkspaceTarget and leaves the fingerprint
// alone, so it reads as a quiet tick and widens the gap. That is the intended outcome: a
// repository whose Git commands keep failing is the last one to retry every 5 seconds.
private async refreshDegradedPollTarget(
workspaceTarget: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<boolean> {
const fingerprintBefore = workspaceTarget.latestFingerprint;
await this.refreshWorkspaceTarget(workspaceTarget, request);
return workspaceTarget.latestFingerprint !== fingerprintBefore;
}
private startWorkingTreeWatchFallback(
target: WorkingTreeWatchTarget,
reason: WorkingTreeWatchFallbackReason,
@@ -1566,19 +1595,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
target.fallbackPolling = true;
target.fallbackPollQuietTickCount = 0;
const { cwd } = target;
const poll = async () => {
target.fallbackPollTimer = null;
if (target.closed || this.workingTreeWatchTargets.get(target.cwd) !== target) {
return;
}
await Promise.all(
const changes = await Promise.all(
Array.from(target.workspaceKeys, async (workspaceKey) => {
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
if (!workspaceTarget) {
return;
return false;
}
await this.refreshWorkspaceTarget(workspaceTarget, {
const changed = await this.refreshDegradedPollTarget(workspaceTarget, {
force: false,
refreshStructure: target.repoRoot === null,
refreshWorktree: true,
@@ -1592,11 +1622,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
workspaceTarget.observationSetupComplete = false;
this.scheduleWorkspaceObservationSetup(workspaceTarget);
}
return changed;
}),
);
this.notifyWorkingTreeConsumers(target);
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS);
target.fallbackPollQuietTickCount = changes.some(Boolean)
? 0
: target.fallbackPollQuietTickCount + 1;
target.fallbackPollTimer = setTimeout(
poll,
this.degradedPollDelayMs(target.fallbackPollQuietTickCount),
);
} else {
target.fallbackPolling = false;
}
@@ -1606,6 +1643,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
{
cwd,
intervalMs: DEGRADED_GIT_POLL_INTERVAL_MS,
maxIntervalMs: DEGRADED_GIT_POLL_MAX_INTERVAL_MS,
reason,
},
"Working tree watcher unavailable; using bounded polling fallback",
@@ -2107,6 +2145,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
subscription: null,
fallbackPolling: false,
fallbackPollTimer: null,
fallbackPollQuietTickCount: 0,
recovery: { attemptCount: 0, timer: null, establishedAt: null },
intervalId: null,
fetchInFlight: false,
@@ -2607,17 +2646,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
target.fallbackPolling = true;
target.fallbackPollQuietTickCount = 0;
const poll = async () => {
target.fallbackPollTimer = null;
if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) {
return;
}
const workingTreeTargets = new Set<WorkingTreeWatchTarget>();
await Promise.all(
const changes = await Promise.all(
Array.from(target.workspaceKeys, async (workspaceKey) => {
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
if (!workspaceTarget) {
return;
return false;
}
this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base");
if (workspaceTarget.latestFacts?.isGit) {
@@ -2627,7 +2667,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (workingTreeTarget) {
workingTreeTargets.add(workingTreeTarget);
}
await this.refreshWorkspaceTarget(workspaceTarget, {
return await this.refreshDegradedPollTarget(workspaceTarget, {
force: false,
refreshStructure: true,
refreshWorktree: true,
@@ -2644,7 +2684,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.notifyWorkingTreeConsumers(workingTreeTarget);
}
if (!target.closed && target.subscription === null) {
target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS);
target.fallbackPollQuietTickCount = changes.some(Boolean)
? 0
: target.fallbackPollQuietTickCount + 1;
target.fallbackPollTimer = setTimeout(
poll,
this.degradedPollDelayMs(target.fallbackPollQuietTickCount),
);
} else {
target.fallbackPolling = false;
}
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { startPathContainmentMetrics, stopPathContainmentMetrics } from "./path.js";
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
import {
searchDirectoryEntries,
@@ -169,6 +170,26 @@ describe("searchDirectoryEntries", () => {
);
});
it.skipIf(isWindows)(
"prunes a symlink whose target is inside a Git-ignored directory",
async () => {
initGitRepo(searchRoot, "generated/\n");
mkdirSync(path.join(searchRoot, "generated", "output"), { recursive: true });
symlinkSync(path.join(searchRoot, "generated", "output"), path.join(searchRoot, "linked"));
await expect(
searchDirectoryEntries({
root: searchRoot,
query: "linked",
pathFormat: "relative",
includeFiles: false,
includeDirectories: true,
respectGitIgnore: true,
}),
).resolves.toEqual([]);
},
);
it("configures raw blank queries independently from explicit root aliases", async () => {
const rootEntries = [
{ path: "projects", kind: "directory" as const },
@@ -953,3 +974,62 @@ describe("relative typed-entry configuration", () => {
expect(results).toEqual([{ path: "blankpage/editor", kind: "directory" }]);
});
});
// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether
// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the
// read and is what made large home directories exceed the client request timeout.
describe("home-tree scan cost", () => {
const DEPTH = 8;
const CHAINS = 40;
let scanRoot: string;
beforeEach(() => {
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-")));
for (let chain = 0; chain < CHAINS; chain += 1) {
const segments: string[] = [];
for (let level = 0; level < DEPTH; level += 1) segments.push(`c${chain}l${level}`);
mkdirSync(path.join(scanRoot, ...segments), { recursive: true });
}
});
afterEach(() => {
rmSync(scanRoot, { recursive: true, force: true });
});
// Nothing matches, so the scan walks the whole tree instead of stopping at a confident result.
function scanWholeTree() {
return searchAbsoluteDirectoryPaths({
homeDir: scanRoot,
query: "nomatch",
maxDepth: DEPTH + 4,
});
}
async function countContainmentChecks(run: () => Promise<unknown>): Promise<number> {
startPathContainmentMetrics();
await run();
return stopPathContainmentMetrics();
}
it("derives no containment for a tree that cannot leave the root", async () => {
const checks = await countContainmentChecks(scanWholeTree);
expect({ checks, scanned: CHAINS * DEPTH }).toEqual({ checks: 0, scanned: 320 });
});
it.skipIf(isWindows)(
"derives containment for symlinked entries, which can leave the root",
async () => {
const outside = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-away-")));
symlinkSync(outside, path.join(scanRoot, "escape"), "dir");
const checks = await countContainmentChecks(scanWholeTree);
expect({ checked: checks > 0, escaped: (await scanWholeTree()).includes(outside) }).toEqual({
checked: true,
escaped: false,
});
rmSync(outside, { recursive: true, force: true });
},
);
});
@@ -46,6 +46,7 @@ interface ChildEntry {
name: string;
resolvedPath: string;
kind: DirectorySuggestionKind;
viaSymlink: boolean;
}
interface RawChildEntry {
@@ -249,7 +250,7 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
async function searchTree(input: SearchInput): Promise<RankedEntry[]> {
if (!(input.maxEntriesScanned > 0)) return [];
const roots = (await readChildren(input.root)).filter((entry) =>
isPathInsideRoot(input.root, entry.resolvedPath),
staysInsideRoot(entry, input.root),
);
const visited = new Set<string>([input.root]);
const branches = roots.flatMap((entry) =>
@@ -294,7 +295,7 @@ async function* walkBranch(
return;
visited.add(entry.resolvedPath);
const children = (await readChildren(entry.resolvedPath)).filter((child) =>
isPathInsideRoot(input.root, child.resolvedPath),
staysInsideRoot(child, input.root),
);
const branches = children.flatMap((child) =>
shouldDiscover(child, input)
@@ -329,8 +330,14 @@ async function* roundRobin<T>(branches: Array<AsyncGenerator<T>>): AsyncGenerato
}
}
// 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 root.
function staysInsideRoot(entry: ChildEntry, root: string): boolean {
return !entry.viaSymlink || isPathInsideRoot(root, entry.resolvedPath);
}
function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
if (isGitIgnoredPath(entry.resolvedPath, input)) return false;
if (isNewlyGitIgnored(entry, input)) return false;
if (entry.kind === "file") {
return input.includeFiles && !entry.name.startsWith(".");
}
@@ -339,6 +346,14 @@ function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
return input.hiddenDirectoryNames.has(entry.name);
}
// Traversal only descends through entries that already passed this check, so every ancestor of
// a named child is known discoverable and only the entry itself can be newly ignored. A symlink
// resolves to a path with different ancestors, so it still needs the full walk.
function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean {
if (entry.viaSymlink) return isGitIgnoredPath(entry.resolvedPath, input);
return input.gitIgnoredPaths.has(entry.resolvedPath);
}
function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean {
let candidate = absolutePath;
while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) {
@@ -712,14 +727,14 @@ function toRawChildEntry(dirent: Dirent): RawChildEntry | null {
async function resolveChild(directory: string, entry: RawChildEntry): Promise<ChildEntry | null> {
const visiblePath = path.join(directory, entry.name);
if (entry.kind !== "symlink") {
return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind };
return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind, viaSymlink: false };
}
const resolvedPath = await realpath(visiblePath).catch(() => null);
if (!resolvedPath) return null;
const info = await stat(resolvedPath).catch(() => null);
const kind = getEntryKind(info);
return kind ? { name: entry.name, resolvedPath, kind } : null;
return kind ? { name: entry.name, resolvedPath, kind, viaSymlink: true } : null;
}
function getEntryKind(info: Stats | null): DirectorySuggestionKind | null {
@@ -728,10 +743,9 @@ function getEntryKind(info: Stats | null): DirectorySuggestionKind | null {
return null;
}
// Reads already reject stale entries by expiry and by directory metadata, so this only has to
// bound the map. Sweeping it for expired keys would cost a full pass on every cache miss.
function pruneCache(): void {
if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) return;
for (const [key, entry] of directoryListCache)
if (entry.expiresAt <= Date.now()) directoryListCache.delete(key);
while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
const key = directoryListCache.keys().next().value;
if (!key) return;
+15
View File
@@ -58,7 +58,22 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
};
}
let pathContainmentChecks: number | null = null;
// Containment is re-derived from scratch on every call. A tree walk that asks it per entry, or
// per ancestor of every entry, costs more than reading the tree does, so tests count the calls.
export function startPathContainmentMetrics(): void {
pathContainmentChecks = 0;
}
export function stopPathContainmentMetrics(): number {
const checks = pathContainmentChecks ?? 0;
pathContainmentChecks = null;
return checks;
}
export function isPathInsideRoot(root: string, candidate: string): boolean {
if (pathContainmentChecks !== null) pathContainmentChecks += 1;
return getRelativePathInsideRoot(root, candidate) !== null;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.9.1",
"version": "0.9.2",
"private": true,
"type": "module",
"scripts": {