Compare commits

..
48 Commits
Author SHA1 Message Date
paseo-bot[bot] 1aef1ec662 test(server): isolate timeline fetch responsiveness from runner work (#8197)
* test(server): isolate timeline fetch responsiveness from runner work

Measure the existing cold tail workload in a separate process after fixture collection. Keep the 50ms gate and row assertions, and reproduce unrelated callback CPU contaminating the old measurement.

* test(server): keep timeline probe private and exercise overlapping CPU

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 12:21:29 +00:00
946b6e58fd Keep transcript colors in sync after a hidden theme change (#8199)
* fix(app): preserve adaptive theme events while hidden

Read the current scheme through fresh matchMedia lists so hidden runtime
reads do not consume the pending events on listener-owned lists.

Carry Dan Sabin's fix from #7172 into the current combined Unistyles patch,
and cover both transition directions with a real Electron regression in
the existing desktop browser suite.

Co-authored-by: Dan Sabin <dan@freethinking.it>

* test(desktop): run adaptive theme regression on Linux

Electron retains --no-sandbox in argv, so the positional lookup used the script filename as the artifact directory. Read the final argument so the same real Electron regression exercises Linux as well as macOS.

Co-authored-by: Nurşat Bülbül <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: Dan Sabin <dan@freethinking.it>

---------

Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: Dan Sabin <dan@freethinking.it>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 11:16:48 +00:00
Mohamed Boudra ac6e4e9f96 Fix flaky 64 MiB image materialization test on Windows CI (#8198)
The responsiveness test timed wall-clock gaps between 1 ms ticks, which reached 50.35 ms on the shared Windows runner for the same code. It now measures main-thread CPU time between ticks and still fails when the payload decodes in one chunk.
2026-10-29 16:56:12 +07:00
paseo-bot[bot] c4b4516890 Keep split dividers under the pointer across mouse and gesture input (#6011)
* fix(app): keep split dividers under the mouse before Explorer is first opened

The main row only left the Explorer pane out once the Explorer had been
shown, because the split container read the registered pane id directly.
Before that, the hidden Explorer stayed in the row with its share of the
sizes, and every divider drag moved about 1.28 times the mouse distance.
Resolve the pane the same way the workspace screen does, so the default
Explorer pane is left out from the start.

Closes #5998

* fix(app): keep split dividers under gesture input

Measure the gesture from its initial press so activation does not discard the movement before its threshold. Cover coarse-pointer mouse and touch input alongside app-created workspaces, Explorer changes and reload.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 09:10:25 +00:00
Mohamed Boudra 75f58c4fe7 fix(release): upload only en-US screenshots to Google Play (#8195)
supply uploads every locale folder under fastlane/metadata/android in one
edit. Play rejects the F-Droid-only ja locale, which failed the whole edit in
the 0.14.0 release ("ja - Invalid request"), so no screenshots were replaced.
Stage only the en-US images, add a standalone workflow to rerun the upload for
the current release, and document the App Store associatedErrors gotcha.
2026-10-29 15:57:08 +07:00
paseo-ai[bot] fb69bf3314 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-29 08:49:01 +00:00
Mohamed Boudra f7eef9dd0c chore(release): reconcile main with v0.14.0 (#8193)
Bring in what v0.14.0 shipped: the 0.14.0 entry in CHANGELOG.md and
CHANGELOG.zh-CN.md exactly as tagged (replacing the 0.14.0-beta.2 series
entry), the 0.14.0 version in every package.json and package-lock.json, and
the generated F-Droid changelogs. The unreleased notes on main stay above the
entry and now say they are not in 0.14.0.

Release commit: 8c6de842a3 (tag v0.14.0)
2026-10-29 15:40:29 +07:00
Mohamed Boudra 7d74ae5d6c docs(release): release from tags with selected cherry-picks while main stays open (#8188)
Replace the next-branch discipline with the main-first workflow: normal PRs
always merge into main, each release builds in a short-lived release workspace
from origin/main or a release tag plus approved cherry-picks, promotions take
an inventory that defaults to defer, and a reconcile PR brings the shipped
changelog, version and F-Droid files back into main.
2026-10-29 15:08:36 +07:00
babo-jeonguk 61841d1ed5 fix(app): stop web menus snapping back to their entering-animation rect (#8180)
Reanimated's web path saves a custom keyframe's element rect when the
animation ends, and writes it back as inline top/left/width/height when
it cleans the keyframe up 750ms after the start. A menu whose content
resized in between, such as a plugin popover whose data had just
arrived, was left clipped to its loading-state size or hanging over its
anchor.

Play the same keyframes through the Web Animations API on web, which
leaves no inline style, and drop the height release that only undid
part of that write. Native keeps the Reanimated Keyframe.
2026-10-29 07:58:09 +00:00
Mohamed Boudra 7c88a48be4 Publish store screenshots from the repo on each stable release (#8186)
* feat(release): publish store screenshots from the repo

Stable releases now upload the committed store renders: the App Store
review lane attaches the iPhone and iPad sets to the version it submits,
and a new Play lane replaces the phone and tablet screenshots after the
Android submit. F-Droid reads the same Android files, which now link to
the store renders instead of the website images.

The Play lane needs its own copy of the submit service-account key
(GOOGLE_PLAY_JSON_KEY in the EAS production environment) because the key
Expo stores for eas submit is not available to custom jobs, and EAS
Metadata does not handle screenshots for either store.

* fix(release): rewrite the Android screenshot links on every render

Google Play and F-Droid read numbered links to the Google renders. The
renderer now rewrites them from the panels it exported, so adding or
renaming a panel reaches all three stores.
2026-10-29 14:39:42 +07:00
paseo-bot[bot]andZeno_ 3196260bd4 Show completed edxeth Pi children as finished, live and after restart (#8176)
* fix(pi): settle edxeth subagent lifecycle events

* test(pi): preserve completed children when restoring captured histories

Verify the completed child and assistant transcript through history replay and the store consumed by clients. Both captured completion protocols otherwise leave a running descriptor.

Co-authored-by: Zeno_ <lin1071833138@gmail.com>

---------

Co-authored-by: Zeno_ <lin1071833138@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 07:06:46 +00:00
paseo-bot[bot] 870a44bc7f Deliver a steer message that arrives while the previous turn is still starting (#6192)
* Deliver a steer message that arrives while the previous turn is still starting

A steer send that reached an agent before its previous turn had a turn id
found no turn to steer and replaced the starting turn, so the earlier
message never reached the provider. Steering now waits for the starting
turn to be accepted (or fail) and steers the turn it became.

Fixes #6186

* Wake a waiting steer only once the starting turn has an id or its run has ended

A failed start used to wake the waiting steer before its run was cleared,
leaving the later send to replace a run that had already failed.

* Clear a failed start's run even when reporting the failure throws

A steer waiting on that start is released only when the run is cleared.

* Test that a failed start keeps a waiting steer until its run is cleared

* fix(server): steer Codex turns while native identification is pending

Wait for the accepted foreground turn to receive its native ID before steering it. Returning unavailable in this window replaced the turn even after the manager had waited for startup.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 05:55:50 +00:00
Mohamed Boudra e8201ab66b Make Claude session entrypoint relabeling optional (#8164)
* fix(claude): make session entrypoint relabeling opt-in

Keep inherited SDK and parent identities cleared. Honor the explicit session-open entrypoint contribution so users can opt into the label through a plugin instead of every session receiving it unconditionally.

* test(hub): use standard budget for restart and worktree recovery

The Windows CI scenario timed out at its custom 20-second limit while the other Hub cases took 4–10 seconds each. This scenario starts two daemons and creates, archives and restores a worktree. Use the existing 30-second suite budget without changing any assertions.
2026-10-29 12:45:41 +07:00
Mohamed Boudra e12feb633f feat(usage): show when each limit resets, and make usage rows easier to read (#8162)
The reset countdown floored to one unit, so a weekly window read "resets 2d"
from 2d 23h down to 2d 0h and looked stuck for a day; the session window had
the same problem with hours. Countdowns now use two units ("2d 23h", "4h 59m"),
and every row also shows the clock time it resets ("7:20 AM", "Sat 8:00 AM",
"Oct 31, 3:00 PM"), so users can plan work around a reset without hovering.

Rows now put the window name and percent on one line, the bar under it, and the
reset on its own full-width line, so the exact time fits at phone width in
longer languages. The pinned-row background is gone; the pin glyph, drawn in
the foreground colour when pinned, is the only pinned signal. Bars are 6px.
2026-10-29 11:24:34 +07:00
Mohamed Boudra 304cbde651 fix(server): reopening a Codex agent no longer reads and holds its full history (#8132)
* fix(server): read Codex history only when Paseo replays it

Reopening a Codex agent whose timeline Paseo still held read the full
thread on connect and kept it on the session until the next replay, which
never came, so every reopened agent held a second copy of its transcript.
thread/resume also returned every turn, and only the sandbox policy was
used from it.

Resume now sends excludeTurns (older Codex ignores the unknown field) and
the history read moves into streamHistory, whose caller owns the result.
A resumed session still needs the earlier turns for rewind targets and the
sub-agent routes that keep an earlier child's interactions on its original
card, so the first turn, goal or rewind indexes the thread and drops the
timeline it read.

* test(server): run reopened Codex agent tests without the Codex CLI installed
2026-10-29 10:17:11 +07:00
paseo-bot[bot]andyayugu 0feaf0d987 Keep configured Claude settings when Paseo adds flags (#8148)
* fix(claude): keep extraArgs settings when Paseo sets flag settings

* test(claude): verify settings survive the real SDK launch

Capture the executable arguments through the public provider session interface so SDK option precedence cannot silently discard the configured settings file again.

Co-authored-by: yayugu <yayugu@gmail.com>

---------

Co-authored-by: yayugu <yayugu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 23:07:21 +00:00
paseo-bot[bot] d3f36e92a5 test(server): isolate config responsiveness from test runner work (#8131)
Measure config reads in a fresh process so unrelated runner callbacks cannot consume the config CPU budget. Keep the existing budget, result, async I/O and yield assertions, and prove isolation with a busy parent callback.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 21:25:31 +00:00
paseo-ai[bot] 1467121b16 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-28 18:00:41 +00:00
Mohamed Boudra 2f0fb2a8ba Bring 0.14.0-beta.2 versions and changelog to main (#8139)
* docs(changelog): fold 0.14.0-beta.2 UI fixes into the 0.14.0 entry

(cherry picked from commit 28bb7e29be)

* chore(release): cut 0.14.0-beta.2

(cherry picked from commit 1a05491be5)

* docs(changelog): keep unreleased main changes out of the 0.14.0-beta.2 entry

The beta.2 tag was cut from a selective hotfix branch, so #8100, #8117 and #8081 are on main but not in beta.2. Every changelog parser (app, website, release-notes sync) skips the preamble, so listing them there keeps them out of the beta.2 release body and What's new until the next release entry absorbs them.
2026-10-29 00:50:24 +07:00
Mohamed Boudraandsuntianc 83ed52669d Resume interrupted agents after host restart (#8127)
* feat(server): resume interrupted agents after host restart

Continue saved running conversations after graceful restart or crash, with host opt-out and a configurable continuation prompt. Preserve explicit launch overrides when reopening the same provider session.

Co-authored-by: coolwind <45729206+coolwindX@users.noreply.github.com>

Co-authored-by: suntianc <suntc8985@gmail.com>

* fix(app): translate restart recovery host settings

* test(app): synchronize sidebar drags and clean up added projects

Wait for the drag sensor click blocker to expire before the next user action. Resolve UI project keys to daemon IDs for cleanup so empty projects do not contaminate later sidebar tests.

* fix(app): tuck the resume prompt behind an edit sheet

Match the System prompt row: an Edit button left of the restart recovery
switch opens the prompt in a sheet, and is disabled while resume is off.

* fix(server): resume interrupted agents without holding startup

Startup now only chooses resume candidates before anything else reads or
repairs persisted running state. Reopening and prompting run in the
background after connections open, so a provider that is slow to reopen
holds back only its own agent. Finish observation waits for a pending
child's resume, so parents are not told the child was closed meanwhile.

* style(server): format restart resume e2e test

* test(server): wait for the fetch result before advancing the narrow refresh debounce

The test released the held fetch, flushed six microtasks, then advanced
the 1s refresh debounce. When the fetch result landed after that advance
(slow Windows runners), the debounce started late and vi.waitFor's
auto-advance reached it only at its real-time timeout. Wait on the
service's in-flight fetch count instead, as the sibling test does.

* test(server): retry stalled OpenCode fixture downloads within the test budget

npm abandons a stalled registry download only after its 300s default
fetch timeout, past the install's 180s budget. The killed install left
the fixture directory locked on Windows, so cleanup failed with EBUSY and
hid the timeout. Against a registry proxy that stalls the first tarball,
a default install hung 225s; with --fetch-timeout it retried and finished
in 16s.

---------

Co-authored-by: suntianc <suntc8985@gmail.com>
2026-10-28 23:40:35 +07:00
Mohamed Boudra 5cfe8f1218 Remove the out-of-date daemon sidebar notice (#8133)
The notice arrived with the terminal worker change (#7644) but is a separate
product change. Remove it with the native/compact callout filtering it needed,
restoring the sidebar callout slot to web-only. Restart and close terminals in
Settings stays.
2026-10-28 23:30:16 +07:00
Mohamed Boudra 38506ed372 test(plugins): allow time for Windows legacy migration workflows (#8112)
The migration case runs real Git operations and several plugin subprocess restarts. Windows CI exceeded its 30-second test deadline and entered cleanup while the operation was still active. Keep every assertion and allow 60 seconds for the complete workflow.
2026-10-28 22:59:21 +07:00
paseo-bot[bot] abc97e0545 Keep an interrupted background Claude subagent canceled after a daemon restart (#6259)
* Keep an interrupted background Claude subagent canceled after a daemon restart

Replay read a background Agent call's immediate async-launch result as the
child's outcome, so every background subagent restored as completed. Skip that
result, and read a child transcript that ends on the interrupt placeholder as
canceled, which is what the live path reported.

* Keep a background Claude subagent running when it got a new prompt after an interrupt

* fix(claude): preserve background subagent outcomes on replay

Use persisted task notifications as replay outcomes with the live status mapping. Diagnostic API request copies contain launch tool results without async metadata, so only transcript messages supply task declarations and tool outcomes.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 14:56:13 +00:00
suntiancandsuntianc 33faacc055 fix(server): release retained plugin streaming snapshots (#7675)
Release cumulative plugin snapshots after adapter attachment and retain canonical replay items. Preserve Unicode text, root/child ordering and current tool state; release history on close.

Co-authored-by: suntianc <suntc8985@gmail.com>
2026-10-28 20:14:12 +07:00
paseo-bot[bot] 8537709e72 test(server): reduce npm fixture setup in the Windows timeout case (#8106)
* test(server): batch npm fixture packing to reduce Windows setup time

* test(server): describe the batched npm fixture setup budget

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 12:57:33 +00:00
Mohamed BoudraandMatt Cowger 9fb7808191 feat(worktrees): let plugins choose creation options and placement (#8117)
* feat(worktrees): let plugins choose creation options and placement

Await the worktree creation request hook before Git source planning so plugins can change placement, branch and base without undoing a fetch. Manage arbitrary destinations through marker and registry ownership, and restore them at their recorded paths.

Co-authored-by: Matt Cowger <matt@cowger.us>

* fix(worktrees): compare repository paths across Windows aliases

Deletion must recognize the same repository through Git native and long path spellings. Reuse ownership path equivalence instead of comparing canonical strings, and let the recovery fixture accept Git platform line endings.

Co-authored-by: Matt Cowger <matt@cowger.us>

* fix(worktrees): respect recorded ownership and creation intent

Keep branch checkout distinct from branch-off, validate plugin placement once at the daemon boundary, and share default path collision resolution. A restored external worktree's marker must never override its recorded non-ownership.

Co-authored-by: Matt Cowger <matt@cowger.us>

* test(worktrees): record ownership in session archive fixture

The fixture creates a Paseo-owned worktree, so its durable record must say so. Archive now treats a recorded false value as authoritative instead of replacing it with filesystem discovery.

Co-authored-by: Matt Cowger <matt@cowger.us>

* test(plugins): retry temp cleanup after worktree archive on Windows

Windows releases directory handles after the daemon closes, so rmdir on the
source repository can hit EBUSY. Retry like the other git temp-dir tests.

* test(plugins): compare plugin-chosen worktree paths in canonical spelling

Windows temp directories come back as 8.3 aliases while the daemon records
the resolved spelling, so normalize the expected root the same way.

---------

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-10-28 18:55:50 +07:00
Sebastian ArenaandMohamed Boudra 41582eb5fd Terminals survive daemon restarts, crashes and updates (#7644)
* M1: terminals survive daemon restarts: stop reasons, detached terminal worker, kill cleanup

restart_server_request.killTerminals / shutdown_server_request.keepTerminals and server_info.features.terminalsSurviveRestart; CLI daemon restart --kill-terminals and daemon stop --keep-terminals (capability-gated). The terminal worker runs detached over an owner-only, secret-authenticated socket and is adopted on daemon start. One kill cleanup contract (the reaper) by birth time, used by the daemon, the supervisor and stopDaemonInstance. Settings offers Restart and close terminals.

* M1: fix review findings: reaper session/table retry, short-command creation order, remote kill-restart cleanup report, stop test port

* M1: fix review findings: reaper-owned cleanup record, session member identity by terminal id, unreadable cleanup records, Settings restart error rendered in the card

* M2: worker protocol versions and draining, worker runs from its own copy, desktop upgrades keep terminals, out-of-date daemon notice, docs/terminal-worker.md

- Daemon classifies live workers by protocol: adopts current, drains one version old, shuts
  down older, leaves a newer one running and answers list/create with
  terminal_worker_incompatible (daemon status prints it). Never more than two workers.
- esbuild worker bundle (build:lib); a built daemon copies bundle, shell integration and
  node-pty into $PASEO_HOME/terminal-worker/<version>-<hash>/ and launches from it; copies no
  record names are pruned.
- Desktop app_update/version_mismatch stop with keep terminals on macOS/Linux; Windows and a
  daemon without the capability close them.
- Out-of-date daemon sidebar notice with Update button, worded by terminalsSurviveRestart.
- PASEO-13: unreadable worker records fail status and kill-restart retry.
- PASEO-14: a session member without a terminal id is unknown, not unrelated.
- Smoke test 46 with A/B installation fixtures and per-fixture CLI shims (PASEO-9 fold).

* M2: track in-flight worker requests without leaving an unhandled rejection

detach({ afterPendingRequests }) tracked each request with request.finally(), which rejects
with the request; a fire-and-forget send that timed out surfaced as an unhandled rejection
(worker-terminal-manager.test.ts). Track settlement only.

* M2: CI round 1 fixes: worker terminal manager in the MCP key test, status cleanup field, shared socket dir

- mcp-server.test.ts (new upstream test) built the worker manager with the old fork API;
  it now starts a real detached worker in a throwaway home.
- daemon-session.test.ts expected status without terminalCleanupUnconfirmed (added in M1).
- paseo-daemon.test.ts: a home under the scratch temp dir is too long for a unix socket, so
  the worker listens in the per-user paseo-<uid> directory, which outlives workers by design;
  the test now requires it to be empty instead of absent.

* M2: run Windows PowerShell by its install path for process identity

server-tests (windows-latest): bootstrap-provider-availability.test.ts starts a daemon with a
PATH that holds only git, and the terminal worker failed to start: 'cannot read its own process
identity: spawn powershell.exe ENOENT'. Process inspection (managed-processes.ts inspect and the
terminal worker's process table) now runs %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe.

* M2: lifecycle e2e plain stop uses the shutdown RPC on Windows

CI round 2, server-integration (windows-latest): 'stop and stop --force never signal processes
a stale or earlier-boot record names' called stopDaemonInstance without a shutdown channel,
which Windows refuses ('has no graceful shutdown channel'). Plain stops in the file now pass
one, as the CLI does.

* M2: native notice outlet, Windows desktop-update wording, worker cap counts unconfirmed cleanups, create recovers after a drain

* M2: phone sidebar mounts the callout slot (iOS notice verified), worker record reads retry transient errors

* M2: native notice preconditions use explicit working directories, one block per terminal

* M1: compact sidebar shows only native callouts on every platform; PTY round trips in the MCP key test get a 5 s window

* ci: pin Playwright browser jobs to ubuntu-24.04

The pinned Playwright (1.58) has no ubuntu-26.04 build, and forks whose
ubuntu-latest already resolves to 26.04 fail at browser install. Job names
are unchanged, so required-check contexts still match.

* ci: use ubuntu-latest for Playwright jobs, same as upstream

Reverts the ubuntu-24.04 pin; upstream main's Playwright shards pass on ubuntu-latest.

* fix(server): keep terminal worker record IO off the daemon event loop

* fix(server): restore terminal creation after worker failure

A disconnected terminal worker remained the current manager indefinitely, leaving dead sessions listed and every later create bound to a closed socket. Confirm process death before retiring the member and use the existing single launch path for replacement. Reconnect live workers and replay their screens while preserving terminal identity.

* test(server): verify orphan cleanup during worker recovery

Recovery reaps orphaned programs before a later daemon stop. The previous fixed-delay assertion raced that cleanup and failed when it completed promptly. Verify orphan removal, same-daemon replacement creation and cleanup on normal or forced stop instead.

* test(server): isolate Windows dependency fixture setup

Build the local package registry in setup so its npm packing processes do not consume the installation journey deadline. Install the platform package used by Windows runtime fixtures directly, avoiding an unused wrapper postinstall probe without a timeout.

* test(server): wait for pending watchers before disposal

Observation admission starts before asynchronous path setup reaches watcher subscription. Disposing at the admission counter races setup and never exercises late watcher cleanup. Wait for the subscriptions to be pending before disposing.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-28 18:04:29 +07:00
Mohamed BoudraandCarlos Precioso dee0d138e9 feat(worktrees): copy included local files before setup and agents (#8100)
* feat(worktrees): copy included local files before setup and agents

Keep local-file materialization in one built-in lifecycle hook so workspace setup and the first agent see the completed copy. Reuse the community plugin algorithm with the source checkout supplied by the worktree hook.

Co-authored-by: Carlos Precioso <github@precioso.design>

* test(plugins): make worktree lifecycle checks portable on Windows

Compare source checkout filesystem identity across Git and Windows path spellings. Exercise copy rollback with a destination file blocking a directory, since Git for Windows accepts directories as exclude files.

---------

Co-authored-by: Carlos Precioso <github@precioso.design>
2026-10-28 17:21:01 +07:00
Mohamed Boudra 986687b8fd feat(plugins): unify lifecycle hooks and await worktree initialization (#8081)
* refactor(plugins): make lifecycle hook timing explicit

Use one registration method and subject.operation.step names so plugin authors can read whether an operation waits. Preserve old registrations through a single SDK adapter and keep timing and payloads unchanged.

* fix(plugins): align hook notes and example fixtures with release contracts

Keep the changelog inside its versioned beta entry for app parsing. Run current plugin examples against the checkout version so their declared requirements remain valid.

* docs(plugins): align translated hook dependency guidance

* fix(plugins): align hook exports and release documentation

* feat(plugins): use lifecycle positions and await worktree initialization

* test(plugins): cover first-agent worktree initialization

* test(plugins): verify rollback against workspace catalog entries
2026-10-28 17:04:52 +07:00
Mohamed Boudra eee70e7fef Show a lone tool call as itself and group from the second call (#8109)
* fix(app): show a lone tool call as itself and group from the second call

A run of one tool call or thought renders as the call. When a second call
arrives the row becomes a group that keeps the first call's id, so the row
key stays put. Tool call and group open state move into a per-agent store:
opening a lone call also opens its future group without notifying the
timeline, so a click re-renders only that tool call.

* test(app): cover lone tool calls turning into groups

Desktop journeys check that an opened lone call keeps its details open inside
the new group, that clicking it renders no timeline component, and that the
group header lands where the call was. Compact covers the call sheet and a
closed group.
2026-10-28 16:34:58 +07:00
paseo-bot[bot] d5a27e0164 fix(server): wait for native watcher delivery before declaring it silent (#8107)
* test(server): expose macOS observation startup failures

* test(server): repeat macOS startup to capture the intermittent failure

* fix(server): probe watcher liveness until native event delivery starts

* test(server): use the production deadline for watcher startup regressions

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 09:29:36 +00:00
8e7e8cce21 fix(app): retain plugin state across host disconnects (#8105)
Keep client plugin installations and local UI state alive through transient host disconnects. Reconcile changed bundles and removals on reconnect and refresh settings changed while offline. Connection switching still replaces the client and reinstalls plugins.

Preserves the implementation from #6731, adds a failing-before/passing-after real browser regression, and corrects the English and Chinese lifecycle documentation. Verified with focused tests, browser reconnect journeys, Android debug offline interaction, root checks and green current-head CI.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-28 15:55:32 +07:00
Mohamed Boudra 36fc4f0b43 chore(ci): remove Android module tests from ordinary CI (#8102)
Native unit tests require an Android prebuild and Maven downloads that block otherwise completed app checks. Keep the explicit local test command while removing its CI invocation and dedicated Java setup.
2026-10-28 14:54:25 +07:00
Mohamed Boudra 4dae66eb8c docs(hub): document reviews when pull requests leave draft (#8104) 2026-10-28 14:26:20 +07:00
Mohamed Boudra d6d5a266ad Open a subagent in place of the subagents list on mobile (#8093)
* fix(app): seat the subagent sheet's parent callout like the composer

The sheet tab body padded the bottom safe-area inset, and the composer dock
padded it again, so the parent callout in a subagent sheet floated a full
navigation-bar height above where the composer sits in a tab.

The sheet body (SheetBottomInset) now owns the inset: it pads it once and
tells its content none is left, so the dock stays host-agnostic. Overlay
sheets (adaptive modal sheet, menu sheet, combobox sheet) read the inset on
the far side of their portal, so ones opened from sheet content still clear
the screen edge.

The callout takes the composer's rail and shows the parent's provider icon
through StatusIcon: no icon box, with the running ring, attention dot or
needs-input alert on the icon.

* fix(app): give the attachment lightbox its own safe area

An RN Modal passes its call site's context straight through, so a lightbox
opened from a sheet tab read the bottom inset the sheet body had already
spent (0) and drew over the navigation bar. The modal is its own window:
it now gets its own SafeAreaProvider, which measures that window, and the
image area reads its insets below it.

floating-panels.md records the rule for overlays: read insets where the
overlay draws.

* feat(app): open a subagent in place of the subagents list on compact

On phones, choosing a subagent from the subagents list now replaces the list
inside the same sheet instead of stacking a second sheet over it. Back returns
to the list, Close closes both, Android Back steps subagent -> list -> closed,
and Open parent lands on the pane the list was opened from (for a nested
subagent, its parent's sheet).

- Menu engine: fill pages (`fill: true`) take the full sheet height, Back pops
  a page via `goBack`, and a page whose definition disappears falls back to
  the list.
- One sheet pane (`screens/workspace/sheet-pane`) renders the header, body,
  bottom inset and focus for both containers: the workspace's detached sheet
  tabs and the in-list menu page. Only the container differs.
- The header drags the sheet through `SheetDragArea`; compact menu sheets use
  the shared `SheetBackground`/`SheetHandle` chrome.
- `selectSheetTab` describes the sheet a reveal would show without opening it.

* fix(app): put the parent callout on one row

The Open parent button already says what the callout is, so the Parent label
goes. The parent's title shrinks to one line before the button does, so Open
parent never wraps or leaves the row.

* refactor(app): register panels once in the workspace screen

Every reader of the panel registry called ensurePanelsRegistered, which
imports every panel module. The panels render those readers (the agent panel's
subagent track and parent callout, the new-tab panel's launcher, the diff
panel's review surface), so each reader closed an import cycle through
register-panels.

The workspace screen, the only route that renders a registry reader, now
registers the panels before anything under it renders, and the readers import
only the leaf panel-registry. The pane content test registers a probe panel for
real instead of mocking the registry.

* test(app): transform the markdown WASM factory when Vite versions its URL

Importing the sheet pane barrel in retained-chat-content.browser.test.tsx made
Vite request md4c.js with a ?v= query, which the CommonJS-to-ESM transform did
not match, so the test file failed to import.
2026-10-28 13:55:41 +07:00
Mohamed Boudra 460ba8e8a3 chore(store): add phone and tablet store screenshots (#8099)
Five panels for the App Store and Google Play in iPhone, Android phone,
iPad 13-inch and Android tablet sizes, rendered from native captures of
the real app against a staged demo host.

The renderer keeps the captures and the store art reproducible: it
resizes every capture with Lanczos to the exact pixels it fills because
Chrome's own downscaling made earlier exports soft, and it fails when a
headline or subline wraps differently, because the device is placed from
that fixed text height. The README is the recapture runbook and the
store-screenshots skill points agents to it.
2026-10-28 13:55:36 +07:00
Mohamed Boudra 7a0c6fbd82 fix(app): remove blank space above Android turn footers (#8088)
Android ships generated Markdown props without code.fontScale, so native measurement wraps inline code at a larger size than drawing. Regenerate both Android code structs and enforce generated-header parity for both platforms.
2026-10-28 13:51:41 +07:00
Mohamed Boudra bede2b90ed fix(app): truncate agent sender attribution to one line (#8092)
Constrain the sender label to the message width so long agent names do not wrap above received messages. Keep the line limit opt-in on Button and cover compact chat widths with a real-browser regression.
2026-10-28 13:50:16 +07:00
Mohamed Boudra befc3d5663 fix(app): restore expansion of long native user messages (#8089)
Native hidden overflow capped the measured content at the preview height, so long messages never offered Show more. Use the native scroll layout mode on the existing View to measure full content while preserving clipping, and store only whether it overflows.
2026-10-28 13:46:43 +07:00
paseo-bot[bot] 53bbd7522d test(server): wait for pending watchers before testing disposal (#8091)
Observation admission precedes asynchronous path resolution. Wait for subscriptions to start before disposal so the test consistently exercises late watcher cleanup.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 06:13:41 +00:00
paseo-bot[bot]andElsa ce412d04c3 Keep docked navigation sizes consistent in narrow windows (#8052)
* fix(app): unify layout and density breakpoints

Align md styles with the existing 600pt compact/wide boundary so
600-719pt tablet windows no longer mix wide layout with compact metrics.
Keep the 500pt composer threshold local to its measured content width.

Let Settings navigation shrink before its detail pane falls below the
existing 400pt target, while retaining the preferred 720pt split budget.

Validated workspace typecheck, lint, formatting, real Chromium resize
checks, Duo portrait and landscape, and existing sidebar geometry tests.

* test(app): cover consistent docked navigation and readable Settings

Compare narrow and wide navigation density and Settings detail space
through the real browser journey, including compact Settings after resize.
The regression fails with main's split layout and density boundaries.

Co-authored-by: Elsa <zeyugao@outlook.com>

---------

Co-authored-by: Elsa <zeyugao@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 06:07:57 +00:00
paseo-bot[bot]andyywl de45902652 fix(server): show reused plugin children as running again (#8080)
* fix(server): report reused plugin children as running again

Child turn starts were discarded after attachment, leaving the previous completed status visible during later work. Translate started to running for live events and ordered history replay.

Co-authored-by: yywl <yywl@linux.0392yy.cn>

* test(server): wait for Git observation before completing fetch

The structural fallback test advanced its debounce timer before asynchronous watcher setup and fetch completion. Wait for those stages so slow filesystem setup does not turn the intended refresh assertion into a scheduling race.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: yywl <yywl@linux.0392yy.cn>
2026-10-28 04:55:10 +00:00
abc23d7721 perf(plugins): minify client bundles while preserving interop and notices (#8076)
Reduce plugin client payloads with whitespace and syntax minification after eager host-export interop. Preserve identifiers, lowered async behavior and dependency license notices; leave server bundles readable.

Verify worker-produced code through registration, byte-reduction, immutable-export and retained-notice regressions. Carries forward #7625 on an upstream branch.

Pin Playwright-installing CI jobs to Ubuntu 24.04 after the ubuntu-latest rollout selected an unsupported browser-install platform.

Follow-up payload review motivated by Matt Cowger's plugin-loading discussion: https://discord.com/channels/1481169421832814616/1563217536269680744

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-28 09:50:29 +07:00
paseo-bot[bot] 2c23cd4927 test(cli): let test daemons allocate their listening ports (#8058)
Reserve-and-release port selection races other listeners during daemon startup. Bind port zero and use the existing daemon instance endpoint for readiness and callers.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 21:27:30 +00:00
paseo-bot[bot]andMatteo Pietro Dazzi de50243885 Keep model-discovery errors away from picker edges (#8039)
* fix(app): pad the model picker empty state on all sides

The empty state carried vertical padding only, so the provider refresh
error it renders wrapped from edge to edge and read as attached to the
model picker's left border on web and mobile.

One padding value keeps the message off the border on every platform.

* test(app): keep model discovery errors inside picker edges

Exercise a real catalog deadline through the existing Pi RPC fixture at desktop and compact browser widths. Both gutter assertions fail without the shared padding fix.

Co-authored-by: Matteo Pietro Dazzi <matteopietro.dazzi@gmail.com>

---------

Co-authored-by: Matteo Pietro Dazzi <matteopietro.dazzi@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 15:59:03 +00:00
Mohamed Boudra 5b7b8d6e81 fix(app): regenerate iOS Markdown inline code props (#8038) 2026-10-27 22:44:49 +07:00
Mohamed BoudraandClaude 25721c7847 fix(app): keep grouped reasoning closed when Expand thinking is off (#8042)
Thoughts are grouped with tool calls only while Expand thinking is off,
yet opening a group rendered each thought with `defaultExpanded`, so the
reasoning text popped open against the setting. Grouped thoughts now open
like any other call: a closed Thinking row the reader clicks.

The folded history also treated the latest retained turn as live whenever
the agent was active without a known turn id, which unfolded the previous
turn until the new turn's rows arrived. Without head rows, the latest turn
stays live only while the active run is that turn.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-27 22:34:13 +07:00
78f580f9e8 perf(plugins): reuse verified bundles and fix repeated timeline preparation (#8040)
Reuse verified host-scoped plugin bundles across client launches and reconstruct complete catalogs when unchanged code is omitted. Keep store ownership on the connected client, including adopted bootstrap clients, and persist in the background with bounded writes.

Remove catalog catch-up deferral and fix repeated timeline preparation that promoted cached completed history into a live head. This preserves completed plugin-rendered chat when catalog installation precedes timeline catch-up.

Carries forward #6732 on an upstream branch, with the original hash/cache contribution and integration attribution preserved.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-27 22:00:23 +07:00
271 changed files with 19705 additions and 2971 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main]
# Every base branch, so stacked PRs and PRs into `next` get the same checks. # Every base branch, so stacked PRs and PRs into any base get the same checks.
pull_request: pull_request:
merge_group: merge_group:
workflow_dispatch: workflow_dispatch:
+25
View File
@@ -203,6 +203,31 @@
"readFileSync", "readFileSync",
"rmSync" "rmSync"
], ],
// Terminal child process: launcher readiness and PTY record ownership run outside the daemon.
"terminal/terminal-worker-process.ts": [
"chmodSync",
"closeSync",
"existsSync",
"openSync",
"rmSync",
"writeSync"
],
// Worker startup only: choose and secure the local listening socket before accepting a controller.
"terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"],
// Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads.
"terminal/terminal-worker-runtime.ts": [
"chmodSync",
"existsSync",
"mkdirSync",
"readdirSync",
"readFileSync",
"renameSync",
"rmSync",
"statSync",
"writeFileSync"
],
// TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together.
"terminal/paseo-cli-path.ts": ["existsSync"],
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop. // Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"], "terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager. // Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
Added:
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0 - 2026-10-29 ## 0.14.0 - 2026-10-29
Plugins can now add tools to individual agents and configure their MCP servers when sessions open. Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
+11
View File
@@ -1,5 +1,16 @@
# 更新日志 # 更新日志
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
新增:
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
改进:
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0 - 2026-10-29 ## 0.14.0 - 2026-10-29
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。 插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
+4 -4
View File
@@ -66,6 +66,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata | | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security | | [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | | [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider | | [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | | [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority | | [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
@@ -116,10 +117,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
## Release branches ## Release branches
When the user says "this goes to next", create or Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
retarget the PR to `next` and preserve that destination through delivery. Follow release builds in a short-lived release workspace from `origin/main` or a release tag; see
[release branch discipline](docs/release.md#release-branch-discipline) for creating [release base and source](docs/release.md#release-base-and-source).
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules ## Critical rules
+24 -2
View File
@@ -12,6 +12,26 @@ initializing → idle → running → idle (or error → closed)
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket. Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Daemon restart recovery
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
Disabling recovery consumes pending intent; enabling it later does not revive old work.
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
Startup chooses candidates before anything else reads or repairs persisted running state, then
opens connections and resumes every candidate in the background. A provider that is slow to reopen
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
closing runtimes.
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
loop or exactly-once turn guarantee.
## Runtime residency ## Runtime residency
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
@@ -139,8 +159,10 @@ caller, child, and host into one watch.
Bootstrap restores these watches after repairing stale running records to `closed` and before opening Bootstrap restores these watches after repairing stale running records to `closed` and before opening
ingress. Reading a closed child's result does not load its provider. A finish notification reports that ingress. Reading a closed child's result does not load its provider. A finish notification reports that
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop recovery is observed only after its resume was sent, so the parent never hears that it was closed
in between. Archived or deleted parents suppress delivery;
parent-owned creation watches also stop
reporting after detachment. reporting after detachment.
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
+3
View File
@@ -66,6 +66,9 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
retaining the daemon's runtime and wire-schema allocations. The server export tests guard retaining the daemon's runtime and wire-schema allocations. The server export tests guard
these dependency trees with tree-shaking disabled to match unbundled production imports. these dependency trees with tree-shaking disabled to match unbundled production imports.
Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that
survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md).
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
+1
View File
@@ -34,6 +34,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced. - **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent. - **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). - **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
+9 -7
View File
@@ -200,13 +200,15 @@ its own.
## Gotchas ## Gotchas
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on - **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
identity changes — a pushed page taller than the one it replaced is clipped without that. after the start. Whatever moved or resized the surface in between was undone: content that
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
entering animation never ends at the off-screen measuring position; on a slow machine that leaves no inline style; native keeps the Reanimated `Keyframe`.
write moved the open menu back off-screen. - **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
so the entering animation runs at the final position rather than ending unseen at the
off-screen measuring position.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is - **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it. A fill page is the one exception. rarely the height of the page before it. A fill page is the one exception.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds - **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
+35 -19
View File
@@ -99,6 +99,10 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI `packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load. checks cover that registry. Unlisted directories do not load.
The `worktree-include` built-in copies gitignored files selected by
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
before setup and agents, through `worktree.create.after`.
Desktop packaging ships the entire built-in plugin directory as an external resource, Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar` including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation. filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
@@ -333,11 +337,10 @@ export default function contribute(client: PluginClientContext) {
} }
``` ```
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when The contribution function must return cleanup for resources created by plugin code. Server cleanup
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query not interrupt host teardown. Client installation retention and disposal follow the public
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup [hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts, Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header. and render error boundary. The contributed component owns the complete body below the header.
@@ -382,6 +385,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS
paused; the daemon accepts clients only after configured plugins have settled and the initial paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete. catalog is complete.
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
passing the bundle string still costs memory and time once per client lifetime.
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
in-memory bundles usable, and a later catalog retries persistence.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host). query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
@@ -428,21 +440,22 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
## Lifecycle hooks ## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks) function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md)
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`, owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved
re-derived on each open and following the plugin's lifecycle. See configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook. every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
`plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. Worktree placement is resolved before Git source planning, so a hook can change the branch and base
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents), without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session; Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
executable definitions stay in the plugin process. ownership checks prefer the marker-verified worktree root over the legacy
path shape: a plugin can place a worktree several directories below the default worktrees root.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
answer a permission through its own daemon session. Awaiting it there deadlocks that command. or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
## Contribute a provider ## Contribute a provider
@@ -613,7 +626,10 @@ Request observation with `client.paseo.agents.list({ subscribe: {} })` and consu
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots [shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
also run after reconnect; replace your view before applying its subsequent updates. The installation also run after reconnect; replace your view before applying its subsequent updates. An installation
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
owns every observation created through its API and releases them on unload, including setup failure. owns every observation created through its API and releases them on unload, including setup failure.
Mounted surfaces and command invocations have shorter API lifetimes. Mounted surfaces and command invocations have shorter API lifetimes.
+135 -73
View File
@@ -6,10 +6,10 @@ All workspaces share one version and release together.
A release has exactly two steps. The agent does the first, the user authorizes the second. A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (local, reversible — agent does this): **Preparation** (reversible — agent does this):
- format, lint, typecheck all green - format, lint, typecheck all green
- resolve the release source to one commit and confirm that commit's existing CI is green - resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
- classify the diff from the previous stable to the release source as patch or minor, then show the - classify the diff from the previous stable to the release source as patch or minor, then show the
target version and rationale to the user target version and rationale to the user
- draft the changelog, show it to the user, wait for review - draft the changelog, show it to the user, wait for review
@@ -17,9 +17,10 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Go-ahead** (user says "go ahead"): **Go-ahead** (user says "go ahead"):
- commit the approved release inputs locally - commit the approved release inputs locally on the release branch
- run the release, which publishes npm and pushes the prepared branch and tag - run the release, which publishes npm and pushes the release branch and tag
- create the release heartbeat immediately and babysit it to completion - create the release heartbeat immediately and babysit it to completion
- [reconcile `main`](#reconcile-main) with what shipped
Rules that apply to both steps: Rules that apply to both steps:
@@ -27,56 +28,128 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits. - No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides. - A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish. - Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release. - If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Release source and CI ## Main stays open
The default release source is `origin/main`. Fetch `origin`, then record the Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
resolved commit. The default release checkout is a clean local `main` whose finalized. Each release builds in a short-lived release workspace, and its published tag records
`HEAD` equals `origin/main`. the shipped source.
An explicit user instruction can select another ref, such as a hotfix commit or ## Choosing a path
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
Before making release-preparation commits, confirm the existing CI run for the The user decides when the changes on `main` warrant a release and how much risk it takes:
resolved commit is green. Pending CI is watched to completion. Release
preparation then stays local through the changelog, any explicitly requested ACP
catalog update, lockfile preparation, and the version commit. After approval,
commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
## Release branch discipline 1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store
review often takes about a day, with no guaranteed timing.
2. **Beta flow**, the default for changes that need testing. Release candidates on the `beta`
channel. Each beta folds into the series' single changelog entry, publishes npm only on the
explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following A beta follows these defaults, which the user can explicitly override:
release. This applies to both beta and stable releases.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active. - Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
- "This goes to next" means create the PR against `next` or retarget an existing while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
PR, and keep that destination through delivery. not count for them.
- Keep `next` current by merging `origin/main` into it as release fixes land. - A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
Avoid rebasing this shared branch because agents and open PRs depend on its history. work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
- After the release ships, bring `next` up to date and open a `next` → `main` PR. them.
Pass CI and merge without squashing away the individual PR commits needed for - Promotion ships the tested beta's product content unchanged and does not pull from `main`.
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated Adding a product change needs the user's explicit selection and a new beta, which soaks again.
`next`. Create it fresh when needed again. - During a soak, ship urgent stable regressions and compatibility or provider model updates as a
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
pace.
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix npm dist-tags are independent pointers:
push runs target only `main`, and GitHub permits only squash merges. Enable
required-check protection for `next`, CI on its pushes, and merge commits for the
integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
### Hotfix from a release tag - A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
newest stable.
- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is
newer than the version `beta` points to, which covers promotions and direct stable releases, so
`@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves
`beta` on the active newer beta.
If `main` contains changes you do not want to release, branch from the affected ## Release base and source
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch The release base is the tag or commit a release workspace starts from. The release source is the
or beta version. Ensure the fixes and changelog also reach `main` and any active base plus any approved cherry-picks, and every source, diff, and CI check uses it.
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track. | Release | Base | Changes on top |
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None |
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` |
| Promotion | The beta tag being promoted | None by default |
| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` |
An explicit user instruction can select another base.
1. Fetch `origin` with tags and resolve the base to one commit.
2. Create the release workspace through Paseo MCP on a new branch from the base, named
`release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from
this workspace: `release:push` pushes `HEAD` to the current branch.
3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each
commit records the `main` commit it came from. Include the prerequisites the inventory listed.
Resolve conflicts within the approved changes and report how. A resolution that needs new
behavior is a scope addition: surface it for selection.
4. Confirm CI for the release source. When the source is a commit whose CI already passed (the
captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to
completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref <branch>`,
because CI push runs target only `main`.
5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
version commit local until the go-ahead. The release command pushes the release branch and tag
as one batch, and the tag push starts the release workflows. Then run
`gh workflow run ci.yml --ref <branch>` so CI covers the complete release commit.
6. [Reconcile `main`](#reconcile-main).
A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3`
shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line
as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode
computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with
`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and
`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts
for the unchanged content.
## Release inventory
When the user asks for a follow-up beta or a promotion, list what `main` has that the base does
not, then get the batch approved. There is no release label or queue.
1. Find the commits on `origin/main` whose patches are not in the base tag:
```bash
git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/main
```
Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag
content over PR state or branch names.
2. Present a short include/defer list. Link each PR and give one plain reason. List separately the
prerequisites an included PR depends on and anything that adds scope beyond what the beta
delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta
preserves its behavior, directly or through a superseding change. List the missing ones for
explicit selection; adding one to a promotion needs a new beta.
3. Wait for the user to approve the batch, unless an explicit selection or autonomous
authorization they already gave covers it.
For a promotion, every item defaults to defer.
## Reconcile main
After the release push, open a PR into `main` that brings in what shipped and keeps newer
development on `main`:
- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or
promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes
below any newer entries.
- The shipped version in every `package.json` and `package-lock.json`, unless `main` already
carries a newer version, plus the F-Droid changelog files a stable release generates.
- No claims about unshipped work: a shipped entry describes only the tagged content, and notes
`main` holds for changes that have not shipped stay out of it.
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
maintains no release track besides `main`.
## ACP catalog updates ## ACP catalog updates
@@ -91,21 +164,6 @@ ACP catalog work enters a release through an explicit user request:
The release authorization covers the requested ACP commit. It ships in the same The release authorization covers the requested ACP commit. It ships in the same
release push as the changelog and version commit. release push as the changelog and version commit.
## Two paths
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision ## Release version decision
Every fresh release starts by classifying the full diff from the previous Every fresh release starts by classifying the full diff from the previous
@@ -133,7 +191,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command: Before running any stable release command:
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean. - Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release. - **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use a release command as a substitute for checking whether the current commit is actually ready. - Do not use a release command as a substitute for checking whether the current commit is actually ready.
@@ -145,9 +203,9 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`. This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, move npm's `beta` pointer to the new stable After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
version for every published package. This changes dist-tags only; do not is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
republish the packages: do not republish the packages:
```bash ```bash
PASEO_VERSION=$(node -p "require('./package.json').version") PASEO_VERSION=$(node -p "require('./package.json').version")
@@ -156,8 +214,8 @@ for package in highlight relay protocol client plugin server cli; do
done done
``` ```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
stable release complete. active newer beta, before considering the stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
@@ -175,7 +233,7 @@ npm run version:all:patch
npm run version:all:minor npm run version:all:minor
npm run release:publish # Publish to npm npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows) npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above. # Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
``` ```
## Beta flow ## Beta flow
@@ -322,7 +380,8 @@ There is no mobile-release workflow under `.github/workflows`. The EAS GitHub ap
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag. Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again. - **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. - When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild.
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well. - **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**. Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
@@ -488,7 +547,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
``` ```
This ensures the checkout ref matches the actual code on `main` with the fix included. This ensures the checkout ref matches the code you want built, with the fix included.
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release - `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only - `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
@@ -631,7 +690,7 @@ Rules:
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs). This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects. When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
## Changelog ordering ## Changelog ordering
@@ -663,11 +722,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
### Beta release ### Beta release
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green - [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release - [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag - [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully - [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green - [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest` - [ ] npm shows the version under the `beta` dist-tag, not `latest`
@@ -678,22 +737,24 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest` - [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body - [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path - [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed - [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
### Stable release (or promotion) ### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings - [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green - [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command - [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command - [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it - [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag - [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully - [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green - [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it - [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset - [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml` - [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
@@ -707,4 +768,5 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review - [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag - [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track - [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed - [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
+69
View File
@@ -0,0 +1,69 @@
# Terminal worker
Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.
## Process model
```
supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
exits (re-parented to init)
```
- The daemon starts the worker through a launcher (`terminal-worker-process.ts --launch`). The launcher spawns the worker `detached`, waits for `ready` on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses `tree-kill`, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
- The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's `listTerminals`.
- A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
## Record, socket and secret
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
## Protocol versions
`TERMINAL_WORKER_PROTOCOL_VERSION` (`terminal-worker-protocol.ts`) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:
| Worker reports | Daemon does |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| its own version | adopts it |
| one version older | adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits |
| two or more older | shuts it down; its terminals close (logged) |
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
## Running from its own copy
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
- `npm run build` bundles the worker with esbuild (`packages/server/scripts/build-terminal-worker.mjs`) into `dist/server/terminal-worker/` with its `shell-integration/` beside it. Only node-pty stays external. A package loaded through a module-local `createRequire` is invisible to esbuild and would be missing at run time; `terminal-worker-runtime.test.ts` fails if the bundle loads anything but built-ins and node-pty.
- Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into `$PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/` and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
- A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
- Dev (`.ts` source) launches from source and copies nothing.
Each create carries the controlling daemon's environment and Paseo CLI path (`baseEnv`, `paseoCliPath`), so a terminal opened after an upgrade gets the new installation's `PASEO_CLI` while older terminals keep theirs.
## Desktop
Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through `daemon stop --keep-terminals` (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.
On Windows every desktop stop closes terminals. In the desktop app the worker runs as `Paseo.exe` with `ELECTRON_RUN_AS_NODE`, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.
## Known limits
- On macOS `ps` has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that `exec`s keeps its entry on every OS.
- On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's `PASEO_TERMINAL_ID`. A job that scrubbed its environment (`env -i`) is reported as unconfirmed, with its pid, and never signaled.
- The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
- Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.
+5
View File
@@ -10,6 +10,11 @@ then replaces the previous tool state or merges into the current text item. Inte
are never retained for history or catch-up. Provider history is the durable transcript authority are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes. and rebuilds the projection when an agent resumes.
Pagination bounds each read, not total resident history. Active and idle agents retain their
projected transcript; archive releases it and later history reads rebuild it from the provider.
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
must be released after their deltas have been consumed.
The invariants are: The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an > A continuously subscribed client applies every committed row in order. Opening or resuming an
+8
View File
@@ -358,6 +358,14 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves `lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
Electron running the old code. Electron running the old code.
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
owned by Unistyles' change listeners. Chromium defers their events while a window
is hidden; reading `matches` on those same lists can consume the pending change.
CSS variables then follow the OS theme while `withUnistyles` props retain the old
colors. Keep this patch until upstream reads the scheme through separate lists.
The desktop browser E2E checks both transition directions in an isolated Electron
process, because Playwright's focus emulation prevents real hidden-page behavior.
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
`patches/` is inert unless that script knows which installed package activates `patches/` is inert unless that script knows which installed package activates
it. it.
+1 -1
View File
@@ -1 +1 @@
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0= sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
@@ -0,0 +1,27 @@
name: Upload Google Play screenshots
# Standalone re-trigger for the release workflow's Google Play screenshot step.
# Replaces the en-US phone and tablet screenshots with the committed renders,
# without rebuilding or resubmitting the app.
on:
workflow_dispatch:
inputs:
android_version_code:
type: string
required: false
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
jobs:
upload_android_screenshots:
name: Upload Google Play screenshots
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
bundle exec fastlane android upload_screenshots
@@ -8,7 +8,11 @@ import {
openDaemonOverview, openDaemonOverview,
restartDaemonInSettings, restartDaemonInSettings,
expectDaemonRestartComplete, expectDaemonRestartComplete,
expectDaemonRestartFailed,
plantUnclosableTerminalProgram,
readWorkerPid, readWorkerPid,
openLongLivedTerminal,
listTerminalIds,
} from "../support/helpers/daemon-lifecycle"; } from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({ test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -29,6 +33,57 @@ test("settings restart waits for a replacement worker while retaining the superv
} }
}); });
test("settings restart keeps a terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const terminalId = await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toContain(terminalId);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals closes the terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toEqual([]);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals shows the processes it could not close", async ({
page,
}, testInfo) => {
test.skip(process.platform === "win32", "The unclosable program is a POSIX zombie");
const daemon = await startIsolatedHostDaemon(randomUUID());
let program: Awaited<ReturnType<typeof plantUnclosableTerminalProgram>> | null = null;
try {
program = await plantUnclosableTerminalProgram(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartFailed(page, `pid ${program.pid}`);
await page.screenshot({
path: testInfo.outputPath("restart-cleanup-failed.png"),
fullPage: true,
});
} finally {
program?.release();
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({ test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page, page,
}) => { }) => {
@@ -1,3 +1,13 @@
import {
withRetainedPlugin,
editRetainedDraft,
expectRetainedDraft,
expectStoredPluginTitle,
hideRetainedPreferences,
showRetainedPreferences,
expectChangedPlugin,
expectRemovedPlugin,
} from "../support/helpers/plugin-reconnect";
import { verifyPluginTranslations } from "../support/helpers/plugin-translations"; import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -188,3 +198,51 @@ test("plugin content and host labels follow app language without losing an open
test.setTimeout(180_000); test.setTimeout(180_000);
await verifyPluginTranslations(page, info); await verifyPluginTranslations(page, info);
}); });
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
page,
}) => {
test.setTimeout(180_000);
await withRetainedPlugin(page, async (probe) => {
await probe.open();
await editRetainedDraft(page, "Connected draft", 0);
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
await probe.disconnect();
await expectRetainedDraft(page, "Connected draft", 1);
await editRetainedDraft(page, "Offline draft", 1);
await probe.changeStoredTitle("Saved by another client");
await expectStoredPluginTitle(page, "Agent monitor");
await probe.reconnect();
await expectStoredPluginTitle(page, "Saved by another client");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("inactive settings refresh without replacing the installation", async () => {
await hideRetainedPreferences(page);
await probe.disconnect();
await probe.changeStoredTitle("Saved while preferences hidden");
await probe.reconnect();
await showRetainedPreferences(page);
await expectStoredPluginTitle(page, "Saved while preferences hidden");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("changed code replaces the retained installation after reconnect", async () => {
await probe.disconnect();
await probe.changeCode();
await expectRetainedDraft(page, "Offline draft", 2);
await probe.reconnect();
await expectChangedPlugin(page);
await editRetainedDraft(page, "New code draft", 0);
});
await test.step("removal is reconciled after reconnect", async () => {
await probe.disconnect();
await probe.remove();
await expectRetainedDraft(page, "New code draft", 1);
await probe.reconnect();
await expectRemovedPlugin(page);
});
});
});
@@ -1,4 +1,5 @@
import { test } from "../support/fixtures"; import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { import {
withTimelinePlugin, withTimelinePlugin,
viewInlineThinkingExample, viewInlineThinkingExample,
@@ -6,6 +7,8 @@ import {
interactWithStreamingCard, interactWithStreamingCard,
expectWholeCompletedCard, expectWholeCompletedCard,
expectBothConsecutiveTools, expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline"; } from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) { for (const width of [1100, 390]) {
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
page, page,
}, info) => { }, info) => {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await withTimelinePlugin(page, info, "assistant", async (agent) => { await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent); await requestPluginTimeline(agent);
await interactWithStreamingCard(page); await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000); await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page); await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff. // Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" }); await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
await reconnectPluginChat(page, gate);
await recovery.expectRecoveredWithoutRetransmission();
await expectWholeCompletedCard(page); await expectWholeCompletedCard(page);
}); });
}); });
@@ -204,19 +204,26 @@ test.describe("Projects settings", () => {
}) => { }) => {
const repo = await createTempGitRepo("projects-settings-empty-"); const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient(); const client = await connectSeedClient();
let projectId: string | null = null; let projectViewKey: string | null = null;
try { try {
await gotoAppShell(page); await gotoAppShell(page);
projectId = await addProjectFromSidebar(page, repo.path); projectViewKey = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId); await openProjectSettingsFromSidebar(page, projectViewKey);
await expectProjectSettingsFormVisible(page); await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible(); await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally { } finally {
if (projectId) { if (projectViewKey) {
await client.removeProject(projectId).catch(() => undefined); const project = (await client.listProjects()).projects.find(
(entry) => entry.projectKey === projectViewKey,
);
expect(project).toBeDefined();
await client.removeProject(project!.projectId);
expect(
(await client.listProjects()).projects.map((entry) => entry.projectKey),
).not.toContain(projectViewKey);
} }
await client.close().catch(() => undefined); await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined); await repo.cleanup().catch(() => undefined);
@@ -30,6 +30,10 @@ import {
addDirectHostFromSettings, addDirectHostFromSettings,
expectHostOnlineWithoutError, expectHostOnlineWithoutError,
expectIdleAgentRelease, expectIdleAgentRelease,
editRestartRecoveryPrompt,
expectRestartRecoveryEnabled,
expectRestartRecoveryPrompt,
setRestartRecoveryEnabled,
setIdleAgentRelease, setIdleAgentRelease,
} from "../support/helpers/settings"; } from "../support/helpers/settings";
@@ -252,3 +256,20 @@ test.describe("Settings host page", () => {
await expectHostActionCards(page, serverId); await expectHostActionCards(page, serverId);
}); });
}); });
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Resume working.");
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
await setRestartRecoveryEnabled(page, false);
await page.reload();
await expectRestartRecoveryEnabled(page, false);
await setRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
@@ -53,11 +53,18 @@ async function quickDragFirstRowAfterSecond(
await page.mouse.move(source.x, source.y + 7); await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0); await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 }); await page.mouse.move(target.x, target.y, { steps: 4 });
await page.mouse.up(); await finishSidebarDrag(page);
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
} }
async function finishSidebarDrag(page: Page): Promise<void> {
await page.mouse.up();
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
// Row order updates before that blocker is removed; wait before the next user action.
await page.waitForTimeout(60);
}
async function dragWithoutReordering(rows: Locator): Promise<void> { async function dragWithoutReordering(rows: Locator): Promise<void> {
const before = await rowTestIds(rows); const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0)); const source = await visibleBoundingBox(rows.nth(0));
@@ -66,7 +73,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 }); await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await page.mouse.up(); await finishSidebarDrag(page);
await expect.poll(() => rowTestIds(rows)).toEqual(before); await expect.poll(() => rowTestIds(rows)).toEqual(before);
} }
@@ -0,0 +1,134 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { createCreationScenario } from "../support/helpers/creation";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
const TOLERANCE_PX = 2;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const input = await page.context().newCDPSession(page);
try {
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await input.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
});
}
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} finally {
await input.detach();
}
}
async function splitIntoFourPanes(page: Page): Promise<void> {
for (const expectedDividers of [1, 2, 3]) {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
}
async function expectDividersTrackPointer(
page: Page,
distance = DRAG_DISTANCE,
drag = dragDivider,
): Promise<void> {
for (const index of [0, 1, 2]) {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await drag(divider, distance);
await expect
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
message: `divider ${index + 1} should stay under the pointer`,
})
.toBeLessThanOrEqual(TOLERANCE_PX);
}
}
for (const hasTouch of [false, true]) {
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
test.use({ hasTouch });
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(
page,
DRAG_DISTANCE,
hasTouch ? dragDividerWithTouch : dragDivider,
);
} finally {
await workspace.cleanup();
}
});
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
const creation = await createCreationScenario(page);
try {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Reply briefly.", "Create");
await creation.expectAssistantReply();
await waitForWorkspaceTabsVisible(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(page);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
await page.reload();
await expect(splitDividers(page)).toHaveCount(3);
await expectDividersTrackPointer(page);
} finally {
await creation.cleanup();
}
});
});
}
+4 -4
View File
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
await openCompactUsageWithClock(page, now); await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account"); const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now"); await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible(); await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time. // The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old. // After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000); await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/); await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible(); await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible(); await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText( await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets (28|29)m$/, /^resets in (28|29)m\s·\s/,
); );
} finally { } finally {
await login.cleanup(); await login.cleanup();
@@ -1,3 +1,7 @@
import { execFile, spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "./daemon-client-loader"; import { connectDaemonClient } from "./daemon-client-loader";
@@ -15,14 +19,27 @@ export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon)
await openHostSection(page, daemon.serverId, "host"); await openHostSection(page, daemon.serverId, "host");
} }
export async function restartDaemonInSettings(page: Page) { export async function restartDaemonInSettings(
const button = page.getByRole("button", { name: "Restart", exact: true }); page: Page,
options: { closeTerminals?: boolean } = {},
) {
const button = page.getByRole("button", {
name: options.closeTerminals ? "Restart and close terminals" : "Restart",
exact: true,
});
await expect(button).toBeEnabled(); await expect(button).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept()); page.once("dialog", (dialog) => dialog.accept());
await button.click(); await button.click();
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled(); await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
} }
export async function expectDaemonRestartFailed(page: Page, text: string) {
const error = page.getByTestId("host-page-restart-error");
await expect(error).toBeVisible({ timeout: 30_000 });
await expect(error).toContainText(text);
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled();
}
export async function expectDaemonRestartComplete(page: Page) { export async function expectDaemonRestartComplete(page: Page) {
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({ await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
timeout: 30_000, timeout: 30_000,
@@ -40,3 +57,100 @@ export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number>
await client.close(); await client.close();
} }
} }
/** Opens a terminal on the isolated daemon that runs a long-lived program. */
export async function openLongLivedTerminal(daemon: IsolatedHostDaemon): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminal",
});
try {
const workspace = await client.createWorkspace({
source: { kind: "directory", path: daemon.paseoHome },
title: "lifecycle",
});
if (!workspace.workspace) throw new Error(workspace.error ?? "Workspace creation failed");
const created = await client.createTerminal(daemon.paseoHome, "lifecycle", undefined, {
workspaceId: workspace.workspace.id,
command: process.execPath,
args: ["-e", "setInterval(() => {}, 1e9)"],
});
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
return created.terminal.id;
} finally {
await client.close();
}
}
export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<string[]> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminals",
});
try {
return (await client.listTerminals()).terminals.map((terminal) => terminal.id);
} finally {
await client.close();
}
}
const execFileAsync = promisify(execFile);
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
});
return stdout.trim();
}
export interface UnclosableTerminalProgram {
pid: number;
/** Ends the program's parent, which lets the system reap the program. */
release(): void;
}
/**
* Leaves in the daemon's home the record a dead terminal worker leaves, naming one terminal
* program that SIGKILL cannot remove: a zombie whose parent (an exec'd `sleep`) never waits for
* it. A kill cleanup can only report it as still running. POSIX only.
*/
export async function plantUnclosableTerminalProgram(
daemon: IsolatedHostDaemon,
): Promise<UnclosableTerminalProgram> {
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
});
parent.unref();
const release = () => {
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
};
try {
const pid = await new Promise<number>((resolve) => {
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
});
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise((resolve) => gone.once("exit", resolve));
const record = {
recordId: "planted-record",
pid: gone.pid,
bootId: null,
createdAt: new Date().toISOString(),
identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" },
protocolVersion: 1,
paseoVersion: "0.0.0-test",
socketPath: path.join(daemon.paseoHome, "planted-worker.sock"),
secret: "planted-secret",
terminals: [{ id: "planted-terminal", pid, startedAt: await psField(pid, "lstart") }],
};
await writeFile(
path.join(daemon.paseoHome, "terminal-worker", `${gone.pid}.json`),
`${JSON.stringify(record, null, 2)}\n`,
{ mode: 0o600 },
);
return { pid, release };
} catch (error) {
release();
throw error;
}
}
@@ -0,0 +1,159 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { settingsRpc } from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { z } from "zod";
import { expect, type Page } from "../fixtures";
import { gotoAppShell } from "./app";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { connectDaemonClient } from "./daemon-client-loader";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
const PLUGIN_ID = "retained-plugin-e2e";
export function retainedPluginSource(version: string) {
return `import { useState } from "react";
import { Text, View, TextInput, Pressable } from "react-native";
import { useSettings } from "@getpaseo/plugin/client";
import { preferences } from "./shared/preferences";
function Preferences({ color }) {
const settings = useSettings(preferences);
return <Text style={{ color }}>{settings.status === "ready" ? "Stored title: " + settings.values.title : "Preferences " + settings.status}</Text>;
}
function Surface({ theme }) {
const [draft, setDraft] = useState("");
const [clicks, setClicks] = useState(0);
const [showPreferences, setShowPreferences] = useState(true);
const color = theme.colors.foreground;
return <View>
<Text style={{ color }}>Plugin version ${version}</Text>
<TextInput accessibilityLabel="Plugin draft" value={draft} onChangeText={setDraft} style={{ color }} />
<Pressable accessibilityRole="button" onPress={() => setClicks(value => value + 1)}><Text style={{ color }}>{"Local clicks " + clicks}</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => setShowPreferences(value => !value)}><Text style={{ color }}>{showPreferences ? "Hide preferences" : "Show preferences"}</Text></Pressable>
{showPreferences ? <Preferences color={color} /> : null}
</View>;
}
export default function contribute(plugin) {
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: "Reconnect probe", icon: "PanelsTopLeft", surface: "main" });
return () => {};
}`;
}
export async function withRetainedPlugin(
page: Page,
run: (probe: Awaited<ReturnType<typeof createProbe>>) => Promise<void>,
) {
const probe = await createProbe(page);
try {
await run(probe);
} finally {
await probe.close();
}
}
async function createProbe(page: Page) {
const example = await copyPluginExample("settings");
const peer = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "plugin-reconnect" });
const previous = await peer.getDaemonConfig();
const gate = await installDaemonWebSocketGate(page);
const clientEntry = path.join(example.directory, "index.client.tsx");
let catalogCount = 0;
try {
await writeFile(
path.join(example.directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(clientEntry, retainedPluginSource("one"));
await peer.patchDaemonConfig({ pluginsEnabled: true });
await peer.installDirectoryPlugin(example.directory);
} catch (error) {
await peer.close();
await example.cleanup();
throw error;
}
return {
async open() {
await page.setViewportSize({ width: 1100, height: 800 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Reconnect probe", exact: true }).click();
await expect(page.getByText("Stored title: Agent monitor", { exact: true })).toBeVisible();
},
async disconnect() {
catalogCount = gate.getClientRequestCount("plugin.catalog.get.request");
await gate.drop();
await gate.waitForBlockedConnection();
},
async reconnect() {
gate.restore();
await gate.waitForServerMessage("plugin.catalog.get.response", catalogCount + 1);
},
async changeStoredTitle(title: string) {
const rpc = settingsRpc("display");
const current = rpc.read.output.parse(
await peer.invokePluginRpc(PLUGIN_ID, rpc.read.name, {}),
);
if (current.status !== "ready") throw new Error("Expected ready settings");
const values = z
.object({ groupBy: z.string(), showMetadata: z.boolean(), title: z.string() })
.parse(current.values);
const result = await peer.invokePluginRpc(PLUGIN_ID, rpc.write.name, {
revision: current.revision,
values: { ...values, title },
});
expect(result).toMatchObject({ status: "saved", values: { title } });
},
async changeCode() {
await writeFile(clientEntry, retainedPluginSource("two"));
await peer.reloadPlugin(PLUGIN_ID);
},
async remove() {
await peer.removePlugin(PLUGIN_ID);
},
async close() {
gate.restore();
await peer.removePlugin(PLUGIN_ID).catch(() => undefined);
await peer.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await peer.close();
await example.cleanup();
},
};
}
export async function editRetainedDraft(page: Page, draft: string, clicks: number) {
await page.getByRole("textbox", { name: "Plugin draft", exact: true }).fill(draft);
await page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }).click();
await expectRetainedDraft(page, draft, clicks + 1);
}
export async function expectRetainedDraft(page: Page, draft: string, clicks: number) {
await expect(page.getByRole("textbox", { name: "Plugin draft", exact: true })).toHaveValue(draft);
await expect(
page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }),
).toBeVisible();
}
export async function expectStoredPluginTitle(page: Page, title: string) {
await expect(page.getByText(`Stored title: ${title}`, { exact: true })).toBeVisible();
}
export async function hideRetainedPreferences(page: Page) {
await page.getByRole("button", { name: "Hide preferences", exact: true }).click();
await expect(page.getByText(/^Stored title:/)).toHaveCount(0);
}
export async function showRetainedPreferences(page: Page) {
await page.getByRole("button", { name: "Show preferences", exact: true }).click();
}
export async function expectChangedPlugin(page: Page) {
await expect(page.getByText("Plugin version two", { exact: true })).toBeVisible();
await expectRetainedDraft(page, "", 0);
}
export async function expectRemovedPlugin(page: Page) {
await expect(
page.getByText("This plugin surface is unavailable.", { exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Reconnect probe", exact: true })).toHaveCount(0);
}
@@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { expectReconnectingToastVisible } from "./workspace-ui";
const PLUGIN_ID = "timeline-transform-e2e"; const PLUGIN_ID = "timeline-transform-e2e";
/** Observe the app's real socket, including fresh sockets after a reload. */
export function trackPluginChatRecovery(page: Page) {
const messages: {
type: string;
payload?: { plugins?: { id: string; clientBundle?: string }[] };
}[] = [];
page.on("websocket", (socket) => {
const collect = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.type === "session") messages.push(frame.message);
};
socket.on("framesent", collect);
socket.on("framereceived", collect);
});
return {
reset() {
messages.length = 0;
},
async expectCatalogWithoutRetransmission() {
await expect
.poll(() =>
messages.some(
(message) =>
message.type === "plugin.catalog.get.response" &&
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
),
)
.toBe(true);
const entry = messages
.find((message) => message.type === "plugin.catalog.get.response")
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
expect(entry).toBeDefined();
expect(entry).not.toHaveProperty("clientBundle");
},
async expectRecoveredWithoutRetransmission() {
await this.expectCatalogWithoutRetransmission();
await expect
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
.toBe(true);
},
};
}
export async function reconnectPluginChat(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
gate.restore();
}
export const ASSISTANT_TEXT = export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming."; "Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
@@ -560,3 +560,41 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online"); await expect(page.getByTestId("host-page-identity")).toContainText("Online");
await expect(page.getByTestId("host-connection-error")).toHaveCount(0); await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
} }
function restartRecoverySwitch(page: Page) {
return page.getByRole("switch", { name: "Resume agents after restart", exact: true });
}
function restartRecoveryEditButton(page: Page) {
return page.getByRole("button", { name: "Edit prompt", exact: true });
}
export async function setRestartRecoveryEnabled(page: Page, enabled: boolean) {
const toggle = restartRecoverySwitch(page);
await toggle.click();
await expectRestartRecoveryEnabled(page, enabled);
}
// The prompt is only editable while restart recovery is on.
export async function expectRestartRecoveryEnabled(page: Page, enabled: boolean) {
await expect(restartRecoverySwitch(page)).toBeChecked({ checked: enabled });
await expect(restartRecoveryEditButton(page)).toBeEnabled({ enabled });
}
export async function editRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await sheet.getByRole("textbox", { name: "Resume prompt", exact: true }).fill(prompt);
await sheet.getByRole("button", { name: "Save", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await expect(sheet.getByRole("textbox", { name: "Resume prompt", exact: true })).toHaveValue(
prompt,
);
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
+17 -1
View File
@@ -26,6 +26,22 @@ def stage_app_store_screenshots
root root
end end
# Google Play only has en-US screenshots, and some F-Droid locales in
# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads
# every locale folder in one edit, so give it only the en-US images.
def stage_google_play_screenshots
root = Dir.mktmpdir("google-play-screenshots")
images = File.join(ANDROID_METADATA, "en-US/images")
%w[phoneScreenshots tenInchScreenshots].each do |kind|
target = File.join(root, "en-US/images", kind)
FileUtils.mkdir_p(target)
Dir.glob(File.join(images, kind, "*.png")).sort.each do |file|
FileUtils.cp(File.realpath(file), File.join(target, File.basename(file)))
end
end
root
end
platform :ios do platform :ios do
desc "Submit the latest TestFlight build to App Store Review" desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do lane :submit_review do
@@ -132,7 +148,7 @@ platform :android do
supply( supply(
package_name: APP_IDENTIFIER, package_name: APP_IDENTIFIER,
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"), json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
metadata_path: ANDROID_METADATA, metadata_path: stage_google_play_screenshots,
version_code: version_code.empty? ? nil : version_code.to_i, version_code: version_code.empty? ? nil : version_code.to_i,
skip_upload_apk: true, skip_upload_apk: true,
skip_upload_aab: true, skip_upload_aab: true,
+21 -14
View File
@@ -49,7 +49,7 @@ export function ResizeHandle({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const finePointer = useHasFinePointer(); const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>()); const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchDragRef = useRef<ResizeHandleDrag | null>(null); const touchStateRef = useRef<PointerState | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null); const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
@@ -151,23 +151,30 @@ export function ResizeHandle({
const touchGesture = useMemo(() => { const touchGesture = useMemo(() => {
const gesture = Gesture.Pan() const gesture = Gesture.Pan()
.runOnJS(true) .runOnJS(true)
.onBegin(() => setDragging(true)) .onBegin((event) => {
.onStart(() => { setDragging(true);
touchDragRef.current = startResizeHandleDrag({ touchStateRef.current = {
sizes, containerSize,
index, pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), drag: startResizeHandleDrag({
commit: (nextSizes) => onResizeSplit(groupId, nextSizes), sizes,
}); index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
}),
};
}) })
.onUpdate((event) => { .onUpdate((event) => {
if (containerSize <= 0) return; const state = touchStateRef.current;
const translation = direction === "horizontal" ? event.translationX : event.translationY; if (!state || state.containerSize <= 0) return;
touchDragRef.current?.move(translation / containerSize); // Gesture Handler resets web translation when the pan activates. Measure from
// the initial press so the activation distance is included on every platform.
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
}) })
.onEnd(() => touchDragRef.current?.finish()) .onEnd(() => touchStateRef.current?.drag.finish())
.onFinalize(() => { .onFinalize(() => {
touchDragRef.current = null; touchStateRef.current = null;
setDragging(false); setDragging(false);
}); });
@@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import { import {
collectAllPanes, collectAllPanes,
createDefaultLayout, createDefaultLayout,
selectExplorerSidebarPaneId,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
type SplitNode, type SplitNode,
type SplitPane, type SplitPane,
@@ -183,8 +184,8 @@ export function SplitContainer({
} | null>(null); } | null>(null);
const maximizedPaneId = const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null; maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerSidebarPaneId = useWorkspaceLayoutStore( const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null, selectExplorerSidebarPaneId(state, workspaceKey),
); );
const mainRoot = useMemo( const mainRoot = useMemo(
@@ -0,0 +1,124 @@
import React, { act, useEffect, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Text, View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* A real browser, because the regression lives in Reanimated's web entering path: it snapshots
* the surface's rect when a custom keyframe ends and writes that rect back as inline
* top/left/width/height when it cleans the keyframe up, 750ms after the animation starts.
* Anything that moved or resized the surface in between is undone by that write.
*/
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
function mount(node: ReactNode): void {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
mounted.push({ root, container });
}
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
const OFFSET = 12;
const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const;
const SHORT: Size = { width: 200, height: 40 };
const TALL: Size = { width: 200, height: 240 };
const WIDE: Size = { width: 480, height: 40 };
/** Inside the window between the entering animation ending and Reanimated's cleanup. */
const GROWS_BEFORE_CLEANUP_MS = 400;
/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */
const GROWS_AFTER_CLEANUP_MS = 1_000;
/** Long enough after the content grew for anything that would write to the surface to land. */
const SETTLE_MS = 1_200;
/** Plain timers, not `act`: an async `act` holds every update until it resolves. */
function wait(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function element(testID: string): HTMLElement {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`);
return found;
}
/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */
function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) {
const [grown, setGrown] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setGrown(true), afterMs);
return () => clearTimeout(timer);
}, [afterMs]);
return <View testID="body" style={grown ? to : from} />;
}
function mountMenuAbovePill(body: ReactNode): void {
mount(
<View style={PILL_POSITION}>
<MenuRoot>
<MenuTrigger testID="pill" accessibilityLabel="Open panel">
<Text>Open panel</Text>
</MenuTrigger>
<MenuSurface
side="top"
align="start"
offset={OFFSET}
minWidth={280}
maxWidth={620}
testID="panel"
>
{body}
</MenuSurface>
</MenuRoot>
</View>,
);
act(() => element("pill").click());
}
describe("anchored menu surface on web", () => {
beforeEach(async () => {
await page.viewport(1280, 900);
});
it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])(
"stays on its anchor when its content grows %ims after opening",
async (afterMs) => {
mountMenuAbovePill(<GrowsAfterOpen afterMs={afterMs} from={SHORT} to={TALL} />);
await wait(afterMs + SETTLE_MS);
const pill = element("pill").getBoundingClientRect();
const panel = element("panel").getBoundingClientRect();
expect(panel.height).toBeGreaterThan(240);
expect(panel.bottom).toBeLessThanOrEqual(pill.top);
},
);
it("widens when its content widens after the entering animation", async () => {
mountMenuAbovePill(<GrowsAfterOpen afterMs={GROWS_BEFORE_CLEANUP_MS} from={SHORT} to={WIDE} />);
await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS);
const body = element("body").getBoundingClientRect();
const panel = element("panel").getBoundingClientRect();
expect(panel.width).toBeGreaterThanOrEqual(body.width);
});
});
@@ -2,6 +2,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useId, useId,
useLayoutEffect,
useMemo, useMemo,
useState, useState,
type ReactElement, type ReactElement,
@@ -47,56 +48,41 @@ const contentEntering = new Keyframe({
100: { opacity: 1, transform: [{ scale: 1 }] }, 100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(CONTENT_ENTERING_DURATION_MS); }).duration(CONTENT_ENTERING_DURATION_MS);
/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */
const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = {
opacity: [0, 1],
transform: ["scale(0.97)", "scale(1)"],
};
/** Web plays the same keyframes itself; see `useWebContentEntering`. */
const nativeContentEntering = isWeb ? undefined : contentEntering;
const contentExiting = new Keyframe({ const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] }, 0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] }, 100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100); }).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
/** /**
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface. * On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
* Once the menu is open, height must return to content-sized so rows can grow in place — and so * web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
* a submenu page taller than the page it replaced is not clipped to the old height. * up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that
* * moved or resized in between — content that grew once its data arrived — snapped back to the old
* `revision` is what makes that second case work: bump it whenever the rendered content changes * rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style.
* identity (a page push, for instance) and the snapshot is released again.
*/ */
function useReleaseFixedMenuHeight({ function useWebContentEntering({
contentSize, placed,
enabled,
surfaceNativeID, surfaceNativeID,
revision,
}: { }: {
contentSize: Size | null; placed: boolean;
enabled: boolean;
surfaceNativeID: string; surfaceNativeID: string;
revision?: string | number;
}): void { }): void {
useEffect(() => { useLayoutEffect(() => {
if (!enabled) return undefined; if (!isWeb || !placed || typeof document === "undefined") return undefined;
const animation = document
const release = () => { .getElementById(surfaceNativeID)
releaseFixedMenuHeight(surfaceNativeID); ?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
if (typeof requestAnimationFrame === "function") { return () => animation?.cancel();
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID)); }, [placed, surfaceNativeID]);
}
};
const timers: ReturnType<typeof setTimeout>[] = [
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
];
if (contentSize) {
timers.push(setTimeout(release, 0));
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [contentSize, enabled, surfaceNativeID, revision]);
} }
/** /**
@@ -224,8 +210,6 @@ export interface AnchoredSurfaceProps {
fullWidth?: boolean; fullWidth?: boolean;
horizontalPadding?: number; horizontalPadding?: number;
scrollable?: boolean; scrollable?: boolean;
/** Bump when the rendered content changes identity, so the height snapshot is released. */
revision?: string | number;
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */ /** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
backdrop?: boolean; backdrop?: boolean;
/** /**
@@ -259,7 +243,6 @@ export function AnchoredSurface({
fullWidth = false, fullWidth = false,
horizontalPadding = 16, horizontalPadding = 16,
scrollable = false, scrollable = false,
revision,
backdrop = true, backdrop = true,
onPointerEnter, onPointerEnter,
onPointerLeave, onPointerLeave,
@@ -269,33 +252,25 @@ export function AnchoredSurface({
}: AnchoredSurfaceProps): ReactElement | null { }: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation(); const { t } = useTranslation();
const surfaceNativeID = useId(); const surfaceNativeID = useId();
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } = const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
useAnchoredPosition({ open,
open, anchorRect,
anchorRect, anchorRef,
anchorRef, side,
side, align,
align, offset,
offset, scrollable,
scrollable, maxHeight,
maxHeight,
});
useReleaseFixedMenuHeight({
contentSize,
enabled: open,
surfaceNativeID,
revision,
}); });
// The surface is placed once both measurements land. Until then it sits off-screen so it can // The surface is placed once both measurements land. Until then it sits off-screen so it can
// be measured, and it must not animate: Reanimated's web entering path snapshots the element's // be measured, and it must not animate: an entering animation that ran there would be over
// rect when the animation ends and writes that rect back as inline top/left when it cleans the // before anyone saw the menu. Remounting on placement starts the native animation from the
// keyframe up 750ms later. If the animation ends before placement, the snapshot is the // final position; web starts its own animation on placement.
// off-screen rect and the menu jumps back off-screen. Remounting on placement starts the
// animation from the final position.
const placed = position !== null; const placed = position !== null;
useWebContentEntering({ placed, surfaceNativeID });
useEffect(() => { useEffect(() => {
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined; if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
@@ -383,7 +358,7 @@ export function AnchoredSurface({
dataSet={surfaceDataSet} dataSet={surfaceDataSet}
style={styles.content} style={styles.content}
frameStyle={frameStyle} frameStyle={frameStyle}
entering={placed ? contentEntering : undefined} entering={placed ? nativeContentEntering : undefined}
exiting={ exiting={
!placed || isWeb || !onExited !placed || isWeb || !onExited
? undefined ? undefined
@@ -344,7 +344,6 @@ function MenuFlyout({
maxHeight={maxHeight} maxHeight={maxHeight}
scrollable={scrollable} scrollable={scrollable}
backdrop={false} backdrop={false}
revision={page.id}
onPointerEnter={cancelHoverClose} onPointerEnter={cancelHoverClose}
onPointerLeave={handleHoverOut} onPointerLeave={handleHoverOut}
testID={testID} testID={testID}
+15
View File
@@ -2157,6 +2157,7 @@ export const ar: TranslationResources = {
minutes_few: "{{value}} دقائق", minutes_few: "{{value}} دقائق",
minutes_many: "{{value}} دقيقة", minutes_many: "{{value}} دقيقة",
minutes_other: "{{value}} دقيقة", minutes_other: "{{value}} دقيقة",
pair: "{{first}} و{{second}}",
}, },
timing: { timing: {
lastUpdated: "آخر تحديث {{relativeTime}}", lastUpdated: "آخر تحديث {{relativeTime}}",
@@ -2164,6 +2165,8 @@ export const ar: TranslationResources = {
resetTimePassed: "مضى وقت إعادة التعيين", resetTimePassed: "مضى وقت إعادة التعيين",
resettingNow: "تتم إعادة التعيين الآن", resettingNow: "تتم إعادة التعيين الآن",
resetsIn: "تتم إعادة التعيين خلال {{duration}}", resetsIn: "تتم إعادة التعيين خلال {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}، {{time}}",
runsOutNow: "ينفد الآن", runsOutNow: "ينفد الآن",
runsOutIn: "ينفد خلال {{duration}}", runsOutIn: "ينفد خلال {{duration}}",
justNow: "الآن", justNow: "الآن",
@@ -2778,6 +2781,14 @@ export const ar: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Connect to this host to manage agents", unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "استئناف الوكلاء بعد إعادة التشغيل",
info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.",
hint: "متابعة العمل المنقطع تلقائيًا.",
prompt: "طلب الاستئناف",
edit: "تحرير",
editAccessibilityLabel: "تعديل الطلب",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "تحرير الوكلاء الخاملين", title: "تحرير الوكلاء الخاملين",
hint: "تحرير الذاكرة بعد ساعة من الخمول.", hint: "تحرير الذاكرة بعد ساعة من الخمول.",
@@ -2873,6 +2884,10 @@ export const ar: TranslationResources = {
requestFailedTitle: "خطأ", requestFailedTitle: "خطأ",
requestFailedMessage: requestFailedMessage:
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.", "فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
closeTerminals: "إعادة التشغيل وإغلاق الطرفيات",
closeTerminalsHint: "يعيد التشغيل ويغلق أيضًا كل الطرفيات المفتوحة على هذا المضيف.",
closeTerminalsConfirmMessage:
"سيُعاد تشغيل الخدمة وتُغلق كل طرفياتها، مما ينهي البرامج التي تعمل فيها. سيعيد التطبيق الاتصال تلقائيًا.",
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.", dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
}, },
update: { update: {
+17 -2
View File
@@ -2164,15 +2164,18 @@ export const en = {
days: "{{value}}d", days: "{{value}}d",
hours: "{{value}}h", hours: "{{value}}h",
minutes: "{{value}}m", minutes: "{{value}}m",
pair: "{{first}} {{second}}",
}, },
timing: { timing: {
lastUpdated: "Last updated {{relativeTime}}", lastUpdated: "Last updated {{relativeTime}}",
noSuccessfulUpdate: "No successful update", noSuccessfulUpdate: "No successful update",
resetTimePassed: "Reset time passed", resetTimePassed: "Reset time passed",
resettingNow: "resetting now", resettingNow: "resetting now",
resetsIn: "resets {{duration}}", resetsIn: "resets in {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "runs out now", runsOutNow: "runs out now",
runsOutIn: "runs out {{duration}}", runsOutIn: "runs out in {{duration}}",
justNow: "just now", justNow: "just now",
daysAgo: "{{value}}d ago", daysAgo: "{{value}}d ago",
hoursAgo: "{{value}}h ago", hoursAgo: "{{value}}h ago",
@@ -2870,6 +2873,14 @@ export const en = {
}, },
agents: { agents: {
unavailable: "Connect to this host to manage agents", unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "Resume agents after restart",
info: "Reopen agents that were working when the host stopped and send the resume prompt. Applies after graceful restarts and crashes.",
hint: "Continue interrupted work automatically.",
prompt: "Resume prompt",
edit: "Edit",
editAccessibilityLabel: "Edit prompt",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Release idle agents", title: "Release idle agents",
hint: "Free up memory after an hour of inactivity.", hint: "Free up memory after an hour of inactivity.",
@@ -2966,6 +2977,10 @@ export const en = {
requestFailedTitle: "Error", requestFailedTitle: "Error",
requestFailedMessage: requestFailedMessage:
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.", "Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
closeTerminals: "Restart and close terminals",
closeTerminalsHint: "Restart and also close every terminal running on this host.",
closeTerminalsConfirmMessage:
"This will restart the daemon and close every terminal on it, ending the programs running in them. The app will reconnect automatically.",
dialogFailedMessage: "Unable to open the restart confirmation dialog.", dialogFailedMessage: "Unable to open the restart confirmation dialog.",
}, },
update: { update: {
+16
View File
@@ -2187,6 +2187,7 @@ export const es: TranslationResources = {
days: "{{value}} d", days: "{{value}} d",
hours: "{{value}} h", hours: "{{value}} h",
minutes: "{{value}} min", minutes: "{{value}} min",
pair: "{{first}} {{second}}",
}, },
timing: { timing: {
lastUpdated: "Última actualización {{relativeTime}}", lastUpdated: "Última actualización {{relativeTime}}",
@@ -2194,6 +2195,8 @@ export const es: TranslationResources = {
resetTimePassed: "Pasó la hora de restablecimiento", resetTimePassed: "Pasó la hora de restablecimiento",
resettingNow: "se restablece ahora", resettingNow: "se restablece ahora",
resetsIn: "se restablece en {{duration}}", resetsIn: "se restablece en {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "se agota ahora", runsOutNow: "se agota ahora",
runsOutIn: "se agota en {{duration}}", runsOutIn: "se agota en {{duration}}",
justNow: "ahora mismo", justNow: "ahora mismo",
@@ -2800,6 +2803,14 @@ export const es: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Connect to this host to manage agents", unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "Reanudar agentes tras reiniciar",
info: "Vuelve a abrir los agentes que estaban trabajando cuando se detuvo el host y envía el mensaje de reanudación. Se aplica tras reinicios normales y fallos.",
hint: "Continuar automáticamente el trabajo interrumpido.",
prompt: "Mensaje de reanudación",
edit: "Editar",
editAccessibilityLabel: "Editar mensaje",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Liberar agentes inactivos", title: "Liberar agentes inactivos",
hint: "Libera memoria tras una hora de inactividad.", hint: "Libera memoria tras una hora de inactividad.",
@@ -2897,6 +2908,11 @@ export const es: TranslationResources = {
requestFailedTitle: "Error", requestFailedTitle: "Error",
requestFailedMessage: requestFailedMessage:
"No se pudo enviar la solicitud de reinicio.Paseose vuelve a conectar automáticamente; inténtelo nuevamente una vez que el host se muestre en línea.", "No se pudo enviar la solicitud de reinicio.Paseose vuelve a conectar automáticamente; inténtelo nuevamente una vez que el host se muestre en línea.",
closeTerminals: "Reiniciar y cerrar terminales",
closeTerminalsHint:
"Reinicia y además cierra todas las terminales abiertas en este host.",
closeTerminalsConfirmMessage:
"Esto reiniciará el daemon y cerrará todas sus terminales, terminando los programas que se ejecutan en ellas. La app se reconectará automáticamente.",
dialogFailedMessage: dialogFailedMessage:
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.", "No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
}, },
+15
View File
@@ -2187,6 +2187,7 @@ export const fr: TranslationResources = {
days: "{{value}} j", days: "{{value}} j",
hours: "{{value}} h", hours: "{{value}} h",
minutes: "{{value}} min", minutes: "{{value}} min",
pair: "{{first}} {{second}}",
}, },
timing: { timing: {
lastUpdated: "Dernière mise à jour {{relativeTime}}", lastUpdated: "Dernière mise à jour {{relativeTime}}",
@@ -2194,6 +2195,8 @@ export const fr: TranslationResources = {
resetTimePassed: "Heure de réinitialisation dépassée", resetTimePassed: "Heure de réinitialisation dépassée",
resettingNow: "réinitialisation en cours", resettingNow: "réinitialisation en cours",
resetsIn: "se réinitialise dans {{duration}}", resetsIn: "se réinitialise dans {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "s’épuise maintenant", runsOutNow: "s’épuise maintenant",
runsOutIn: "s’épuise dans {{duration}}", runsOutIn: "s’épuise dans {{duration}}",
justNow: "à l’instant", justNow: "à l’instant",
@@ -2803,6 +2806,14 @@ export const fr: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Connectez-vous à cet hôte pour gérer les agents", unavailable: "Connectez-vous à cet hôte pour gérer les agents",
resumeOnStartup: {
title: "Reprendre les agents après un redémarrage",
info: "Rouvre les agents qui travaillaient lorsque l’hôte s’est arrêté et envoie le message de reprise. S’applique après un redémarrage normal ou un plantage.",
hint: "Continuer automatiquement le travail interrompu.",
prompt: "Message de reprise",
edit: "Modifier",
editAccessibilityLabel: "Modifier le message",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Libérer les agents inactifs", title: "Libérer les agents inactifs",
hint: "Libère de la mémoire après une heure d’inactivité.", hint: "Libère de la mémoire après une heure d’inactivité.",
@@ -2902,6 +2913,10 @@ export const fr: TranslationResources = {
requestFailedTitle: "Erreur", requestFailedTitle: "Erreur",
requestFailedMessage: requestFailedMessage:
"Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.", "Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.",
closeTerminals: "Redémarrer et fermer les terminaux",
closeTerminalsHint: "Redémarre et ferme aussi tous les terminaux ouverts sur cet hôte.",
closeTerminalsConfirmMessage:
"Le daemon va redémarrer et fermer tous ses terminaux, ce qui arrêtera les programmes qui y tournent. L'app se reconnectera automatiquement.",
dialogFailedMessage: dialogFailedMessage:
"Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.", "Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.",
}, },
+15
View File
@@ -2160,6 +2160,7 @@ export const ja: TranslationResources = {
days: "{{value}}日", days: "{{value}}日",
hours: "{{value}}時間", hours: "{{value}}時間",
minutes: "{{value}}分", minutes: "{{value}}分",
pair: "{{first}}{{second}}",
}, },
timing: { timing: {
lastUpdated: "最終更新: {{relativeTime}}", lastUpdated: "最終更新: {{relativeTime}}",
@@ -2167,6 +2168,8 @@ export const ja: TranslationResources = {
resetTimePassed: "リセット時刻を過ぎています", resetTimePassed: "リセット時刻を過ぎています",
resettingNow: "まもなくリセット", resettingNow: "まもなくリセット",
resetsIn: "{{duration}}後にリセット", resetsIn: "{{duration}}後にリセット",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "まもなく上限に到達", runsOutNow: "まもなく上限に到達",
runsOutIn: "{{duration}}後に上限に到達", runsOutIn: "{{duration}}後に上限に到達",
justNow: "たった今", justNow: "たった今",
@@ -2767,6 +2770,14 @@ export const ja: TranslationResources = {
}, },
agents: { agents: {
unavailable: "エージェントを管理するにはこのホストに接続してください", unavailable: "エージェントを管理するにはこのホストに接続してください",
resumeOnStartup: {
title: "再起動後にエージェントを再開",
info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。",
hint: "中断された作業を自動的に続行します。",
prompt: "再開メッセージ",
edit: "編集",
editAccessibilityLabel: "メッセージを編集",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "アイドル状態のエージェントを解放", title: "アイドル状態のエージェントを解放",
hint: "1 時間のアイドル状態の後にメモリを解放します。", hint: "1 時間のアイドル状態の後にメモリを解放します。",
@@ -2865,6 +2876,10 @@ export const ja: TranslationResources = {
requestFailedTitle: "エラー", requestFailedTitle: "エラー",
requestFailedMessage: requestFailedMessage:
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。", "再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
closeTerminals: "再起動してターミナルを閉じる",
closeTerminalsHint: "再起動し、このホストのすべてのターミナルも閉じます。",
closeTerminalsConfirmMessage:
"デーモンを再起動し、すべてのターミナルを閉じます。ターミナルで実行中のプログラムは終了します。アプリは自動的に再接続します。",
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。", dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
}, },
update: { update: {
+15
View File
@@ -2149,6 +2149,7 @@ export const ko: TranslationResources = {
days: "{{value}}일", days: "{{value}}일",
hours: "{{value}}시간", hours: "{{value}}시간",
minutes: "{{value}}분", minutes: "{{value}}분",
pair: "{{first}} {{second}}",
}, },
timing: { timing: {
lastUpdated: "마지막 업데이트: {{relativeTime}}", lastUpdated: "마지막 업데이트: {{relativeTime}}",
@@ -2156,6 +2157,8 @@ export const ko: TranslationResources = {
resetTimePassed: "초기화 시간이 지났습니다", resetTimePassed: "초기화 시간이 지났습니다",
resettingNow: "지금 재설정 중", resettingNow: "지금 재설정 중",
resetsIn: "{{duration}} 후 재설정", resetsIn: "{{duration}} 후 재설정",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "지금 한도에 도달", runsOutNow: "지금 한도에 도달",
runsOutIn: "{{duration}} 후 한도에 도달", runsOutIn: "{{duration}} 후 한도에 도달",
justNow: "방금", justNow: "방금",
@@ -2755,6 +2758,14 @@ export const ko: TranslationResources = {
}, },
agents: { agents: {
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요", unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
resumeOnStartup: {
title: "재시작 후 에이전트 재개",
info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.",
hint: "중단된 작업을 자동으로 계속합니다.",
prompt: "재개 메시지",
edit: "편집",
editAccessibilityLabel: "메시지 편집",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "유휴 에이전트 해제", title: "유휴 에이전트 해제",
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.", hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
@@ -2852,6 +2863,10 @@ export const ko: TranslationResources = {
requestFailedTitle: "오류", requestFailedTitle: "오류",
requestFailedMessage: requestFailedMessage:
"재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.", "재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.",
closeTerminals: "재시작하고 터미널 닫기",
closeTerminalsHint: "재시작하면서 이 호스트의 모든 터미널도 닫습니다.",
closeTerminalsConfirmMessage:
"데몬을 재시작하고 모든 터미널을 닫습니다. 터미널에서 실행 중인 프로그램은 종료됩니다. 앱은 자동으로 다시 연결됩니다.",
dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.", dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.",
}, },
update: { update: {
+15
View File
@@ -2172,6 +2172,7 @@ export const ptBR: TranslationResources = {
days: "{{value}} d", days: "{{value}} d",
hours: "{{value}} h", hours: "{{value}} h",
minutes: "{{value}} min", minutes: "{{value}} min",
pair: "{{first}} {{second}}",
}, },
timing: { timing: {
lastUpdated: "Última atualização {{relativeTime}}", lastUpdated: "Última atualização {{relativeTime}}",
@@ -2179,6 +2180,8 @@ export const ptBR: TranslationResources = {
resetTimePassed: "O horário de redefinição passou", resetTimePassed: "O horário de redefinição passou",
resettingNow: "redefinindo agora", resettingNow: "redefinindo agora",
resetsIn: "redefine em {{duration}}", resetsIn: "redefine em {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "esgota agora", runsOutNow: "esgota agora",
runsOutIn: "esgota em {{duration}}", runsOutIn: "esgota em {{duration}}",
justNow: "agora mesmo", justNow: "agora mesmo",
@@ -2782,6 +2785,14 @@ export const ptBR: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Conecte-se a este host para gerenciar agentes", unavailable: "Conecte-se a este host para gerenciar agentes",
resumeOnStartup: {
title: "Retomar agentes após reiniciar",
info: "Reabre os agentes que estavam trabalhando quando o host parou e envia a mensagem de retomada. Aplica-se após reinicializações normais e falhas.",
hint: "Continuar automaticamente o trabalho interrompido.",
prompt: "Mensagem de retomada",
edit: "Editar",
editAccessibilityLabel: "Editar mensagem",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Liberar agentes ociosos", title: "Liberar agentes ociosos",
hint: "Libera memória após uma hora de inatividade.", hint: "Libera memória após uma hora de inatividade.",
@@ -2880,6 +2891,10 @@ export const ptBR: TranslationResources = {
requestFailedTitle: "Erro", requestFailedTitle: "Erro",
requestFailedMessage: requestFailedMessage:
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.", "Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
closeTerminals: "Reiniciar e fechar terminais",
closeTerminalsHint: "Reinicia e também fecha todos os terminais abertos neste host.",
closeTerminalsConfirmMessage:
"Isso reiniciará o daemon e fechará todos os terminais dele, encerrando os programas que rodam neles. O app vai se reconectar automaticamente.",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.", dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
}, },
update: { update: {
+15
View File
@@ -2172,6 +2172,7 @@ export const ru: TranslationResources = {
days: "{{value}} дн.", days: "{{value}} дн.",
hours: "{{value}} ч", hours: "{{value}} ч",
minutes: "{{value}} мин", minutes: "{{value}} мин",
pair: "{{first}} {{second}}",
}, },
timing: { timing: {
lastUpdated: "Последнее обновление {{relativeTime}}", lastUpdated: "Последнее обновление {{relativeTime}}",
@@ -2179,6 +2180,8 @@ export const ru: TranslationResources = {
resetTimePassed: "Время сброса прошло", resetTimePassed: "Время сброса прошло",
resettingNow: "сброс сейчас", resettingNow: "сброс сейчас",
resetsIn: "сброс через {{duration}}", resetsIn: "сброс через {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "закончится сейчас", runsOutNow: "закончится сейчас",
runsOutIn: "закончится через {{duration}}", runsOutIn: "закончится через {{duration}}",
justNow: "только что", justNow: "только что",
@@ -2792,6 +2795,14 @@ export const ru: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами", unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
resumeOnStartup: {
title: "Возобновлять агентов после перезапуска",
info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.",
hint: "Автоматически продолжать прерванную работу.",
prompt: "Сообщение для продолжения",
edit: "Редактировать",
editAccessibilityLabel: "Изменить сообщение",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Освобождать неактивных агентов", title: "Освобождать неактивных агентов",
hint: "Освобождает память после часа бездействия.", hint: "Освобождает память после часа бездействия.",
@@ -2890,6 +2901,10 @@ export const ru: TranslationResources = {
requestFailedTitle: "Ошибка", requestFailedTitle: "Ошибка",
requestFailedMessage: requestFailedMessage:
"Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.", "Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.",
closeTerminals: "Перезапустить и закрыть терминалы",
closeTerminalsHint: "Перезапускает демон и закрывает все терминалы на этом хосте.",
closeTerminalsConfirmMessage:
"Демон будет перезапущен, а все его терминалы закрыты вместе с запущенными в них программами. Приложение переподключится автоматически.",
dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.", dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.",
}, },
update: { update: {
+15
View File
@@ -2117,6 +2117,7 @@ export const zhCN: TranslationResources = {
days: "{{value}}天", days: "{{value}}天",
hours: "{{value}}小时", hours: "{{value}}小时",
minutes: "{{value}}分钟", minutes: "{{value}}分钟",
pair: "{{first}}{{second}}",
}, },
timing: { timing: {
lastUpdated: "上次更新于 {{relativeTime}}", lastUpdated: "上次更新于 {{relativeTime}}",
@@ -2124,6 +2125,8 @@ export const zhCN: TranslationResources = {
resetTimePassed: "重置时间已过", resetTimePassed: "重置时间已过",
resettingNow: "正在重置", resettingNow: "正在重置",
resetsIn: "将在 {{duration}}后重置", resetsIn: "将在 {{duration}}后重置",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "即将用尽", runsOutNow: "即将用尽",
runsOutIn: "将在 {{duration}}后用尽", runsOutIn: "将在 {{duration}}后用尽",
justNow: "刚刚", justNow: "刚刚",
@@ -2713,6 +2716,14 @@ export const zhCN: TranslationResources = {
}, },
agents: { agents: {
unavailable: "连接到这个 Host 以管理 Agent", unavailable: "连接到这个 Host 以管理 Agent",
resumeOnStartup: {
title: "重启后恢复智能体",
info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。",
hint: "自动继续被中断的工作。",
prompt: "恢复提示",
edit: "编辑",
editAccessibilityLabel: "编辑提示",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "释放空闲 Agent", title: "释放空闲 Agent",
hint: "空闲一小时后释放内存。", hint: "空闲一小时后释放内存。",
@@ -2804,6 +2815,10 @@ export const zhCN: TranslationResources = {
offlineMessage: "这个 Host 已离线。Paseo 会自动重连,请等它恢复在线后再重启。", offlineMessage: "这个 Host 已离线。Paseo 会自动重连,请等它恢复在线后再重启。",
requestFailedTitle: "错误", requestFailedTitle: "错误",
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。", requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
closeTerminals: "重启并关闭终端",
closeTerminalsHint: "重启的同时关闭此主机上的所有终端。",
closeTerminalsConfirmMessage:
"这将重启守护进程并关闭其所有终端,终端中运行的程序会被结束。应用会自动重新连接。",
dialogFailedMessage: "无法打开重启确认对话框。", dialogFailedMessage: "无法打开重启确认对话框。",
}, },
update: { update: {
@@ -0,0 +1,424 @@
import appPackage from "../../package.json";
import { afterEach, expect, it } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { settingsRpc } from "@getpaseo/plugin";
import { syncPluginCatalog } from "./catalog-sync-model";
import { createPluginHosts } from "./hosts";
import { PluginRegistry } from "./registry";
import { pluginSettingsKey } from "./settings/query";
import type { InstalledPlugin } from "./types";
const audio = { play: async () => 0 };
const rpc = settingsRpc("preferences");
type Catalog = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>;
type Observation = ReturnType<DaemonClient["observeEvents"]>;
type Observer = Parameters<Observation["subscribe"]>[0];
class SettingsStore {
private revision = 1;
private values = { label: "original" };
reads = 0;
read() {
this.reads++;
return { status: "ready", revision: String(this.revision), values: this.values } as const;
}
write(input: unknown) {
const write = rpc.write.input.parse(input);
if (write.revision !== String(this.revision)) {
return { status: "conflict", error: "Settings changed" } as const;
}
const values = write.values;
if (typeof values !== "object" || values === null || !("label" in values)) {
throw new Error("Expected settings values");
}
if (typeof values.label !== "string") throw new Error("Expected label");
this.values = { label: values.label };
this.revision++;
return { status: "saved", revision: String(this.revision), values: this.values } as const;
}
}
/** In-memory daemon port; it delivers owned snapshots and stops updates on release. */
class CatalogClient extends DaemonClient {
catalog = catalog("one");
fetches = 0;
releases = 0;
events: Parameters<DaemonClient["observeEvents"]>[0] = [];
private readonly observers = new Set<Observer>();
constructor(readonly settings = new SettingsStore()) {
super({ url: "ws://unused.test", clientId: "catalog-sync-test" });
}
override async getPluginCatalog(): Promise<Catalog> {
this.fetches++;
return this.catalog;
}
override observeEvents(events: Parameters<DaemonClient["observeEvents"]>[0]): Observation {
this.events = events;
const snapshot = { requestId: "events", subscriptionId: "catalog" };
const subscribers = new Set<Observer>();
return {
subscriptionId: snapshot.subscriptionId,
ready: Promise.resolve(snapshot),
subscribe: (observer) => {
subscribers.add(observer);
this.observers.add(observer);
observer.snapshot(snapshot);
return () => {
subscribers.delete(observer);
this.observers.delete(observer);
};
},
release: async () => {
this.releases++;
for (const observer of subscribers) this.observers.delete(observer);
subscribers.clear();
},
};
}
override async invokePluginRpc(_pluginId: string, method: string, input: unknown) {
if (method === rpc.read.name) return this.settings.read();
if (method === rpc.write.name) return this.settings.write(input);
throw new Error(`Unexpected RPC: ${method}`);
}
update(message: SessionOutboundMessage) {
for (const observer of this.observers) observer.update(message);
}
snapshot() {
for (const observer of this.observers) {
observer.snapshot({ requestId: "reconnect", subscriptionId: "catalog" });
}
}
}
function catalog(marker: string): Catalog {
return [
{
id: "reports",
requirements: { paseo: `>=${appPackage.version}` },
clientBundle: `(function() { return { default: function(plugin) {
plugin.addTimelineTransformer({
id: "report", query: { itemType: "tool_call" },
transform() { return ${JSON.stringify(marker)} ? { items: [] } : undefined; }
});
return function() { plugin.openSettings("cleanup"); };
} }; })`,
},
];
}
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0).toReversed()) cleanup();
});
function harness() {
let evaluations = 0;
let disposals = 0;
const registry = new PluginRegistry({
localeSource: { getLocale: () => "en", subscribe: () => () => {} },
version: appPackage.version,
createRuntime(installation) {
evaluations++;
return {
hosts: createPluginHosts(
{
getHosts: () => [],
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
},
installation.lifetime.signal,
),
paseo: installation.paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected runtime RPC");
},
openSettings: () => {
disposals++;
},
openScreen() {},
openSurface() {},
async playAudio() {},
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
};
},
});
const client = new CatalogClient();
cleanups.push(() => {
registry.removeHost("host");
registry.removeHost("other");
});
function sync(options: Partial<Parameters<typeof syncPluginCatalog>[0]> = {}) {
const stop = syncPluginCatalog({
serverId: "host",
client,
audio,
connected: true,
supported: true,
registry,
...options,
});
cleanups.push(stop);
return stop;
}
function installed(serverId = "host") {
const plugin = registry.getSnapshot().find((entry) => entry.serverId === serverId);
if (!plugin) throw new Error(`Missing installation: ${serverId}`);
return plugin;
}
return {
registry,
client,
sync,
installed,
evaluations: () => evaluations,
disposals: () => disposals,
};
}
async function connected() {
const h = harness();
const stop = h.sync();
await expect.poll(() => h.registry.getSnapshot().length).toBe(1);
return { ...h, stop };
}
function settingsQuery(plugin: InstalledPlugin) {
return {
queryKey: pluginSettingsKey("preferences"),
queryFn: async () => rpc.read.output.parse(await plugin.invoke(rpc.read.name, {})),
// The replica policy used by useSettings deliberately disables automatic refetches.
gcTime: Infinity,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retry: false,
} as const;
}
it("retains installations and timeline transforms while disconnected", async () => {
const h = await connected();
const plugin = h.installed();
const snapshot = h.registry.getSnapshot();
const transformers = plugin.timelineTransformers;
h.stop();
h.sync({ connected: false });
h.client.update({
type: "status",
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
});
expect(h.registry.getSnapshot()).toBe(snapshot);
expect(h.installed().timelineTransformers).toBe(transformers);
expect(plugin.lifetime.signal.aborted).toBe(false);
expect(h.disposals()).toBe(0);
expect(h.client.fetches).toBe(1);
expect(h.client.events).toEqual([
"status.plugin_catalog_changed",
"status.plugin_settings_changed",
]);
});
it.each(["mounted", "unmounted"])(
"refreshes %s settings and revisions after another client saves offline",
async (surface) => {
const h = await connected();
const plugin = h.installed();
const snapshot = h.registry.getSnapshot();
const options = settingsQuery(plugin);
const original = await plugin.queryClient.fetchQuery(options);
const observer = new QueryObserver(plugin.queryClient, options);
const unsubscribe = observer.subscribe(() => {});
cleanups.push(unsubscribe);
if (surface === "unmounted") unsubscribe();
plugin.queryClient.setQueryData(["attachment-search"], "retained");
const otherClient = new CatalogClient();
h.registry.installCatalog("other", otherClient.catalog, { client: otherClient, audio });
const otherPlugin = h.installed("other");
await otherPlugin.queryClient.fetchQuery(settingsQuery(otherPlugin));
// Another host publishes new presentation snapshots without replacing the owning lifetime.
const beforeReconnect = h.registry.getSnapshot();
const retainedInstallation = h.installed();
h.stop();
h.sync({ connected: false });
const clientB = new CatalogClient(h.client.settings);
expect(
await clientB.invokePluginRpc("reports", rpc.write.name, {
revision: original.revision,
values: { label: "client B" },
}),
).toEqual({ status: "saved", revision: "2", values: { label: "client B" } });
expect(plugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
h.sync();
await expect
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
.toEqual({
status: "ready",
revision: "2",
values: { label: "client B" },
});
const refreshed = await plugin.queryClient.fetchQuery(options);
expect(
await plugin.invoke(rpc.write.name, {
revision: refreshed.revision,
values: { label: "client A" },
}),
).toEqual({ status: "saved", revision: "3", values: { label: "client A" } });
expect(h.registry.getSnapshot()).toBe(beforeReconnect);
expect(h.installed()).toBe(retainedInstallation);
expect(h.installed().lifetime).toBe(plugin.lifetime);
expect(h.installed().queryClient).toBe(plugin.queryClient);
expect(h.installed().timelineTransformers).toBe(snapshot[0].timelineTransformers);
expect(plugin.queryClient.getQueryData(["attachment-search"])).toBe("retained");
expect(otherClient.settings.reads).toBe(1);
expect(otherPlugin.queryClient.getQueryState(options.queryKey)?.isInvalidated).toBe(false);
expect(otherPlugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
expect(h.evaluations()).toBe(2);
expect(h.disposals()).toBe(0);
},
);
it("replaces changed bundles on reconnect and disposes the old installation", async () => {
const h = await connected();
const plugin = h.installed();
plugin.queryClient.setQueryData(["attachment-search"], "old");
h.stop();
h.client.catalog = catalog("two");
h.sync();
await expect.poll(() => h.installed() === plugin).toBe(false);
expect(plugin.lifetime.signal.aborted).toBe(true);
expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]);
expect(h.installed().timelineTransformers.map((entry) => entry.id)).toEqual(["report"]);
expect(h.evaluations()).toBe(2);
expect(h.disposals()).toBe(1);
});
it("replaces an explicitly reloaded bundle even when its source is unchanged", async () => {
const h = await connected();
const plugin = h.installed();
h.client.update({
type: "status",
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
});
await expect.poll(() => h.installed() === plugin).toBe(false);
expect(h.disposals()).toBe(1);
expect(h.evaluations()).toBe(2);
});
it.each(["unsupported", "no audio"])("disposes plugins when %s", async (reason) => {
const h = await connected();
const plugin = h.installed();
h.stop();
h.sync({ supported: reason !== "unsupported", audio: reason === "no audio" ? null : audio });
expect(h.registry.getSnapshot()).toEqual([]);
expect(plugin.lifetime.signal.aborted).toBe(true);
expect(h.disposals()).toBe(1);
});
it("refreshes settings on a replayed subscription snapshot without reinstalling", async () => {
const h = await connected();
const plugin = h.installed();
const options = settingsQuery(plugin);
await plugin.queryClient.fetchQuery(options);
h.client.settings.write({ revision: "1", values: { label: "replayed" } });
h.client.snapshot();
await expect
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
.toEqual({
status: "ready",
revision: "2",
values: { label: "replayed" },
});
expect(h.installed()).toBe(plugin);
expect(h.evaluations()).toBe(1);
});
it("invalidates only the named settings on its host for a live notification", async () => {
const h = await connected();
const plugin = h.installed();
const options = settingsQuery(plugin);
await plugin.queryClient.fetchQuery(options);
const observer = new QueryObserver(plugin.queryClient, options);
cleanups.push(observer.subscribe(() => {}));
plugin.queryClient.setQueryData(pluginSettingsKey("other-settings"), "unchanged");
const other = new CatalogClient();
h.registry.installCatalog("other", other.catalog, { client: other, audio });
await h.installed("other").queryClient.fetchQuery(settingsQuery(h.installed("other")));
const beforeNotification = h.registry.getSnapshot();
const retainedInstallation = h.installed();
h.client.settings.write({ revision: "1", values: { label: "live" } });
h.client.update({
type: "status",
payload: {
status: "plugin_settings_changed",
pluginId: "reports",
settingsId: "preferences",
},
});
await expect
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
.toEqual({
status: "ready",
revision: "2",
values: { label: "live" },
});
expect(plugin.queryClient.getQueryState(pluginSettingsKey("other-settings"))?.isInvalidated).toBe(
false,
);
expect(other.settings.reads).toBe(1);
expect(h.registry.getSnapshot()).toBe(beforeNotification);
expect(h.installed()).toBe(retainedInstallation);
expect(h.installed().lifetime).toBe(plugin.lifetime);
expect(h.installed().queryClient).toBe(plugin.queryClient);
expect(h.evaluations()).toBe(2);
expect(h.client.fetches).toBe(1);
});
it("ignores an in-flight catalog snapshot after disconnect", async () => {
const h = await connected();
const plugin = h.installed();
await plugin.queryClient.fetchQuery(settingsQuery(plugin));
const snapshot = h.registry.getSnapshot();
h.stop();
let resolveCatalog: (value: Catalog) => void = () => {};
const pending = new Promise<Catalog>((resolve) => {
resolveCatalog = resolve;
});
let fetching: () => void = () => {};
const started = new Promise<void>((resolve) => {
fetching = resolve;
});
class PendingClient extends CatalogClient {
override getPluginCatalog() {
fetching();
return pending;
}
}
const stop = h.sync({ client: new PendingClient(h.client.settings) });
await started;
stop();
resolveCatalog(catalog("late"));
await pending;
// Drain the catalog continuation through a subsequently queued task.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(h.registry.getSnapshot()).toBe(snapshot);
expect(h.client.settings.reads).toBe(1);
expect(h.evaluations()).toBe(1);
});
@@ -0,0 +1,93 @@
import type { AudioEngine } from "@/audio";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginRegistry } from "./registry";
import { pluginSettingsKey, pluginSettingsPrefix } from "./settings/query";
interface CatalogSyncInput {
serverId: string;
client: DaemonClient;
audio: Pick<AudioEngine, "play"> | null;
connected: boolean;
supported: boolean;
registry: Pick<PluginRegistry, "installCatalog" | "getSnapshot" | "removeHost">;
}
export function syncPluginCatalog({
serverId,
client,
audio,
connected,
supported,
registry,
}: CatalogSyncInput): () => void {
if (!supported || !audio) {
registry.removeHost(serverId);
return () => {};
}
// A dropped socket reconnects on the same client, so installations keep rendering offline.
if (!connected) return () => {};
const installationOptions = { client, audio };
let cancelled = false;
let refreshQueue = Promise.resolve();
function refresh(replacePluginId?: string) {
refreshQueue = refreshQueue.then(() =>
client
.getPluginCatalog()
.then((catalog) => {
if (!cancelled) {
registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId });
}
return undefined;
})
.catch((error) => {
if (!cancelled) {
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
}
return undefined;
}),
);
return refreshQueue;
}
const observation = client.observeEvents([
"status.plugin_catalog_changed",
"status.plugin_settings_changed",
]);
observation.subscribe({
snapshot: () => {
void refresh().then(() => {
if (cancelled) return undefined;
// Retained replicas miss offline writes and do not refetch when a screen mounts.
for (const plugin of registry.getSnapshot()) {
if (plugin.serverId !== serverId) continue;
void plugin.queryClient.invalidateQueries({
queryKey: pluginSettingsPrefix,
refetchType: "all",
});
}
return undefined;
});
},
update: (message) => {
if (message.type !== "status") return;
if (message.payload.status === "plugin_settings_changed") {
const { pluginId, settingsId } = message.payload;
if (typeof settingsId === "string") {
const plugin = registry
.getSnapshot()
.find((item) => item.serverId === serverId && item.id === pluginId);
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
}
}
if (message.payload.status === "plugin_catalog_changed") {
const pluginId = message.payload.pluginId;
if (typeof pluginId === "string") void refresh(pluginId);
}
},
});
return () => {
cancelled = true;
void observation
.release()
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
};
}
+13 -67
View File
@@ -1,10 +1,10 @@
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { pluginSettingsKey } from "./settings/use-settings";
import { useEffect } from "react"; import { useEffect } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostFeature } from "@/runtime/host-features"; import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { pluginRegistry } from "./registry"; import { pluginRegistry } from "./registry";
import { syncPluginCatalog } from "./catalog-sync-model";
export function PluginCatalogSync({ export function PluginCatalogSync({
serverId, serverId,
@@ -17,72 +17,18 @@ export function PluginCatalogSync({
const connected = useHostRuntimeIsConnected(serverId); const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "plugins"); const supported = useHostFeature(serverId, "plugins");
useEffect(() => { useEffect(
let cancelled = false; () =>
let refreshQueue = Promise.resolve(); syncPluginCatalog({
if (!supported || !audio) { serverId,
pluginRegistry.removeHost(serverId); client,
return; audio,
} connected,
if (!connected) { supported,
pluginRegistry.removeHost(serverId); registry: pluginRegistry,
return; }),
} [audio, client, connected, serverId, supported],
const refresh = (replacePluginId?: string) => { );
refreshQueue = refreshQueue.then(() =>
client
.getPluginCatalog()
.then((catalog) => {
if (!cancelled) {
pluginRegistry.installCatalog(serverId, catalog, {
replacePluginId,
client,
audio,
});
}
return undefined;
})
.catch((error) => {
if (!cancelled) {
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
}
return undefined;
}),
);
return refreshQueue;
};
const observation = client.observeEvents([
"status.plugin_catalog_changed",
"status.plugin_settings_changed",
]);
observation.subscribe({
snapshot: () => {
void refresh();
},
update: (message) => {
if (message.type !== "status") return;
if (message.payload.status === "plugin_settings_changed") {
const { pluginId, settingsId } = message.payload;
if (typeof settingsId === "string") {
const plugin = pluginRegistry
.getSnapshot()
.find((item) => item.serverId === serverId && item.id === pluginId);
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
}
}
if (message.payload.status === "plugin_catalog_changed") {
const pluginId = message.payload.pluginId;
if (typeof pluginId === "string") void refresh(pluginId);
}
},
});
return () => {
cancelled = true;
void observation
.release()
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
};
}, [audio, client, connected, serverId, supported]);
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
return null; return null;
@@ -0,0 +1,32 @@
import * as FileSystem from "expo-file-system/legacy";
import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto";
import { createPluginClientBundleStore } from "./store";
export function createHostPluginClientBundleStore(serverId: string) {
const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`;
const ensureDirectory = async () => {
if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable");
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
};
return createPluginClientBundleStore(
{
async list() {
await ensureDirectory();
return FileSystem.readDirectoryAsync(directory);
},
read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`),
async writeAtomic(name, contents) {
await ensureDirectory();
const temporary = `${directory}${randomUUID()}.tmp`;
try {
await FileSystem.writeAsStringAsync(temporary, contents);
await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` });
} finally {
await FileSystem.deleteAsync(temporary, { idempotent: true });
}
},
remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }),
},
(contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents),
);
}
@@ -0,0 +1,62 @@
import { createPluginClientBundleStore } from "./store";
const DATABASE_NAME = "paseo-plugin-client-bundles";
const STORE_NAME = "bundles";
let opening: Promise<IDBDatabase> | undefined;
function openDatabase(): Promise<IDBDatabase> {
opening ??= new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME));
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
}).catch((error) => {
opening = undefined;
throw error;
});
return opening;
}
export function createHostPluginClientBundleStore(serverId: string) {
const prefix = `${encodeURIComponent(serverId)}/`;
const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`);
async function run<T>(
mode: IDBTransactionMode,
operation: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, mode);
const request = operation(transaction.objectStore(STORE_NAME));
transaction.addEventListener("complete", () => resolve(request.result));
transaction.addEventListener("abort", () => reject(transaction.error));
transaction.addEventListener("error", () => reject(transaction.error));
});
}
return createPluginClientBundleStore(
{
async list() {
return (await run("readonly", (store) => store.getAllKeys(range())))
.filter((key): key is string => typeof key === "string")
.map((key) => key.slice(prefix.length));
},
async read(name) {
const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`));
if (typeof result !== "string") throw new Error("Missing plugin client bundle");
return result;
},
async writeAtomic(name, contents) {
await run("readwrite", (store) => store.put(contents, `${prefix}${name}`));
},
async remove(name) {
await run("readwrite", (store) => store.delete(`${prefix}${name}`));
},
},
async (contents) => {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
},
);
}
@@ -0,0 +1,36 @@
import { expect, test } from "vitest";
import { createHostPluginClientBundleStore } from "./index.web";
async function sha256(contents: string) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
test("browser bundles survive a fresh store and remain isolated by host", async () => {
const first = createHostPluginClientBundleStore("bundle-test-host-a");
const second = createHostPluginClientBundleStore("bundle-test-host-b");
const clientBundle = "bundle".repeat(250_000);
const entry = {
id: "same-plugin",
clientBundle,
clientBundleHash: await sha256(clientBundle),
};
const otherCode = "other host code";
const other = {
id: entry.id,
clientBundle: otherCode,
clientBundleHash: await sha256(otherCode),
};
try {
await first.replace([entry]);
await second.replace([other]);
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]);
expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]);
await first.replace([]);
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]);
expect(await second.load()).toEqual([other]);
} finally {
await first.replace([]);
await second.replace([]);
}
});
@@ -0,0 +1,179 @@
import { createHash } from "node:crypto";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { expect, test } from "vitest";
import { createPluginClientBundleStore, type PluginBundleFiles } from "./store";
const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex");
class BundleFiles implements PluginBundleFiles {
readonly contents = new Map<string, string>();
readonly writes: string[] = [];
async list() {
return [...this.contents.keys()];
}
async read(name: string) {
const contents = this.contents.get(name);
if (contents === undefined) throw new Error("File disappeared");
return contents;
}
async writeAtomic(name: string, contents: string) {
this.writes.push(name);
this.contents.set(name, contents);
}
async remove(name: string) {
this.contents.delete(name);
}
}
async function bundle(id: string, clientBundle: string) {
return { id, clientBundle, clientBundleHash: await sha256(clientBundle) };
}
test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => {
const files = new BundleFiles();
const old = await bundle("changed", "old");
const kept = await bundle("kept", "Unicode: 折叠屏 🐚");
await createPluginClientBundleStore(files, sha256).replace([old, kept]);
const cold = createPluginClientBundleStore(files, sha256);
expect(await cold.load()).toEqual([old, kept]);
const changed = await bundle("changed", "new");
await cold.replace([kept, changed]);
expect(files.contents.size).toBe(2);
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]);
expect(files.writes).toHaveLength(3);
await cold.replace([]);
expect(files.contents.size).toBe(0);
});
test("same-length corruption, truncated files and unreadable files are not advertised", async () => {
const files = new BundleFiles();
const entries = await Promise.all([
bundle("same-length", "good"),
bundle("truncated", "long"),
bundle("missing", "lost"),
]);
await createPluginClientBundleStore(files, sha256).replace(entries);
const names = [...files.contents.keys()];
files.contents.set(names[0]!, "evil");
files.contents.set(names[1]!, "lo");
// The directory entry still exists while reading the file fails.
const unreadable: PluginBundleFiles = {
list: () => files.list(),
read: (name) =>
name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name),
writeAtomic: (name, contents) => files.writeAtomic(name, contents),
remove: (name) => files.remove(name),
};
const cold = createPluginClientBundleStore(unreadable, sha256);
expect(await cold.load()).toEqual([]);
await cold.replace(entries);
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries);
});
test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => {
const files = new BundleFiles();
const previous = await bundle("plugin", "previous");
await createPluginClientBundleStore(files, sha256).replace([previous]);
const failing: PluginBundleFiles = {
list: () => files.list(),
read: (name) => files.read(name),
remove: (name) => files.remove(name),
writeAtomic: async () => {
throw new Error("Disk full");
},
};
await expect(
createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]),
).rejects.toThrow("Disk full");
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]);
});
// Exercise the app store through a fresh platform-agnostic client, using its transport port.
test.each(["valid", "corrupt", "missing"])(
"cold catalog uses a %s persisted bundle correctly",
async (state) => {
const files = new BundleFiles();
const entry = await bundle("plugin", "good code");
await createPluginClientBundleStore(files, sha256).replace([entry]);
if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code");
if (state === "missing") files.contents.clear();
let open = () => {};
let receive: (data: unknown, binary: boolean) => void = () => {};
const requests: Record<string, unknown>[] = [];
const client = new DaemonClient({
url: "ws://test",
clientId: "plugin-cache-test",
reconnect: { enabled: false },
createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256),
transportFactory: () => ({
send(data) {
if (typeof data !== "string") return;
const frame = JSON.parse(data);
if (frame.type !== "session") return;
const request = frame.message;
if (request.type !== "plugin.catalog.get.request") return;
requests.push(request);
const { clientBundle: _code, ...reference } = entry;
receive(
JSON.stringify({
type: "session",
message: {
type: "plugin.catalog.get.response",
payload: {
requestId: request.requestId,
plugins: [request.cachedClientBundles ? reference : entry],
},
},
}),
false,
);
},
close() {},
onOpen(handler) {
open = handler;
return () => {};
},
onMessage(handler) {
receive = handler;
return () => {};
},
onClose() {
return () => {};
},
onError() {
return () => {};
},
}),
});
try {
const connecting = client.connect();
open();
receive(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "cache-host",
hostname: null,
version: null,
features: { plugins: true },
},
},
}),
false,
);
await connecting;
expect(await client.getPluginCatalog()).toEqual([entry]);
if (state === "valid")
expect(requests[0]?.cachedClientBundles).toEqual([
{ id: entry.id, clientBundleHash: entry.clientBundleHash },
]);
else expect(requests[0]).not.toHaveProperty("cachedClientBundles");
await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]);
} finally {
await client.close();
}
},
);
@@ -0,0 +1,56 @@
import type {
PluginClientBundle,
PluginClientBundleStore,
} from "@getpaseo/client/internal/daemon-client";
/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */
export interface PluginBundleFiles {
list(): Promise<string[]>;
read(name: string): Promise<string>;
writeAtomic(name: string, contents: string): Promise<void>;
remove(name: string): Promise<void>;
}
export function createPluginClientBundleStore(
files: PluginBundleFiles,
sha256: (contents: string) => Promise<string>,
): PluginClientBundleStore {
const verified = new Set<string>();
const filename = (bundle: PluginClientBundle) =>
`${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`;
return {
async load() {
const bundles: PluginClientBundle[] = [];
for (const name of await files.list()) {
try {
const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name);
if (!match) continue;
const id = decodeURIComponent(match[1]!);
const clientBundleHash = match[2]!;
const clientBundle = await files.read(name);
if ((await sha256(clientBundle)) !== clientBundleHash) continue;
verified.add(name);
bundles.push({ id, clientBundleHash, clientBundle });
} catch {
// Missing, unreadable or corrupt files are cache misses, never executable code.
}
}
return bundles;
},
async replace(bundles) {
const retained = new Set<string>();
for (const bundle of bundles) {
const name = filename(bundle);
retained.add(name);
if (verified.has(name)) continue;
await files.writeAtomic(name, bundle.clientBundle);
verified.add(name);
}
for (const name of await files.list()) {
if (retained.has(name)) continue;
await files.remove(name);
verified.delete(name);
}
},
};
}
@@ -219,7 +219,10 @@ test("locale snapshots preserve installation state and observations, then releas
expect(after.paseo).toBe(before.paseo); expect(after.paseo).toBe(before.paseo);
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component); expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
expect(h.open).toEqual(new Set(["notes"])); expect(h.open).toEqual(new Set(["notes"]));
const translatedSnapshot = h.plugins.getSnapshot();
h.plugins.installCatalog("host", [translated], { client: h.client, audio }); h.plugins.installCatalog("host", [translated], { client: h.client, audio });
expect(h.plugins.getSnapshot()).toBe(translatedSnapshot);
expect(h.plugins.getSnapshot()[0].locale).toBe("zh-CN");
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n); expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
h.plugins.removeHost("host"); h.plugins.removeHost("host");
expect(listeners.size).toBe(0); expect(listeners.size).toBe(0);
+33 -2
View File
@@ -27,12 +27,12 @@ const pluginRegistry = {
installCatalog( installCatalog(
serverId: string, serverId: string,
catalog: Parameters<typeof registry.installCatalog>[1], catalog: Parameters<typeof registry.installCatalog>[1],
options: { replacePluginId?: string } = {}, options: { replacePluginId?: string; client?: DaemonClient } = {},
) { ) {
return registry.installCatalog( return registry.installCatalog(
serverId, serverId,
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })), catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
{ ...options, client: daemonClient, audio }, { client: daemonClient, ...options, audio },
); );
}, },
}; };
@@ -118,6 +118,37 @@ describe("PluginRegistry", () => {
expect(second?.queryClient.getQueryData(["counter"])).toBe(3); expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
}); });
it("keeps the snapshot when the same catalog is installed again", () => {
const clientBundle = timelineBundle("one");
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
const snapshot = pluginRegistry.getSnapshot();
const transformers = snapshot[0]?.timelineTransformers;
const listener = vi.fn();
const unsubscribe = pluginRegistry.subscribe(listener);
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
unsubscribe();
expect(pluginRegistry.getSnapshot()).toBe(snapshot);
expect(pluginRegistry.getSnapshot()[0]?.timelineTransformers).toBe(transformers);
expect(listener).not.toHaveBeenCalled();
});
it("reinstalls an unchanged bundle for a new host client", () => {
const clientBundle = bundle("one");
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
const first = pluginRegistry.getSnapshot()[0];
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
client: {} as DaemonClient,
});
const second = pluginRegistry.getSnapshot()[0];
expect(second).not.toBe(first);
expect(first?.lifetime.signal.aborted).toBe(true);
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
});
it("replaces the query cache when the plugin bundle changes", () => { it("replaces the query cache when the plugin bundle changes", () => {
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
const first = pluginRegistry.getSnapshot()[0]; const first = pluginRegistry.getSnapshot()[0];
+22 -7
View File
@@ -19,6 +19,8 @@ export class PluginRegistry {
private snapshot: InstalledPlugin[] = []; private snapshot: InstalledPlugin[] = [];
private readonly disposed = new WeakSet<InstalledPlugin>(); private readonly disposed = new WeakSet<InstalledPlugin>();
private readonly evaluationErrors = new Map<string, string>(); private readonly evaluationErrors = new Map<string, string>();
/** The client each host's installations were bound to; their Paseo API and RPCs call it. */
private readonly clientByHost = new Map<string, DaemonClient>();
constructor( constructor(
private readonly dependencies: { private readonly dependencies: {
@@ -49,10 +51,15 @@ export class PluginRegistry {
}, },
): boolean { ): boolean {
const previous = this.byHost.get(serverId) ?? []; const previous = this.byHost.get(serverId) ?? [];
const previousErrors = this.hostEvaluationErrors(serverId);
// Installations call the client they were created with, so a new client replaces them all.
const sameClient = this.clientByHost.get(serverId) === options.client;
this.clientByHost.set(serverId, options.client);
const previousTimelineBundles = previous const previousTimelineBundles = previous
.filter((plugin) => plugin.timelineTransformers.length > 0) .filter((plugin) => plugin.timelineTransformers.length > 0)
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`); .map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
const preserved = catalog.flatMap((entry) => { const preserved = catalog.flatMap((entry) => {
if (!sameClient) return [];
const existing = previous.find( const existing = previous.find(
(plugin) => (plugin) =>
plugin.id !== options.replacePluginId && plugin.id !== options.replacePluginId &&
@@ -146,13 +153,13 @@ export class PluginRegistry {
} }
} }
this.byHost.set(serverId, installed); this.byHost.set(serverId, installed);
if ( // An unchanged catalog keeps the snapshot, so timeline projections cached against the
installed.length !== catalog.length || // installed transformers stay valid across a reconnect.
installed.length !== previous.length || const unchanged =
installed.some((plugin, index) => plugin !== previous[index]) installed.length === previous.length &&
) { installed.every((plugin, index) => plugin === previous[index]) &&
this.publish(); this.hostEvaluationErrors(serverId) === previousErrors;
} if (!unchanged) this.publish();
const installedTimelineBundles = installed const installedTimelineBundles = installed
.filter((plugin) => plugin.timelineTransformers.length > 0) .filter((plugin) => plugin.timelineTransformers.length > 0)
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`); .map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
@@ -170,9 +177,17 @@ export class PluginRegistry {
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key); if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
} }
this.byHost.delete(serverId); this.byHost.delete(serverId);
this.clientByHost.delete(serverId);
this.publish(); this.publish();
} }
private hostEvaluationErrors(serverId: string): string {
const prefix = `${serverId}/`;
return JSON.stringify(
[...this.evaluationErrors].filter(([key]) => key.startsWith(prefix)).sort(),
);
}
private dispose(plugin: InstalledPlugin): void { private dispose(plugin: InstalledPlugin): void {
if (this.disposed.has(plugin)) return; if (this.disposed.has(plugin)) return;
this.disposed.add(plugin); this.disposed.add(plugin);
@@ -0,0 +1,2 @@
export const pluginSettingsPrefix = ["plugin-settings"] as const;
export const pluginSettingsKey = (id: string) => [...pluginSettingsPrefix, id] as const;
@@ -6,8 +6,8 @@ import { type SettingsDefinition } from "@getpaseo/plugin";
import { settingsRpc } from "@getpaseo/plugin"; import { settingsRpc } from "@getpaseo/plugin";
import { useReplicaQuery } from "@/data/query"; import { useReplicaQuery } from "@/data/query";
import { z, type ZodType } from "zod"; import { z, type ZodType } from "zod";
import { pluginSettingsKey } from "./query";
export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const;
function message(error: unknown): string { function message(error: unknown): string {
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
+4
View File
@@ -1,3 +1,4 @@
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
import { watchdog } from "@/watchdog"; import { watchdog } from "@/watchdog";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client"; import type { OwnedSubscription } from "@getpaseo/client";
@@ -589,6 +590,7 @@ function createDefaultDeps(
capabilities: appCapabilities, capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
providerSnapshots: "wire", providerSnapshots: "wire",
createPluginClientBundleStore: createHostPluginClientBundleStore,
...(host.password ? { password: host.password } : {}), ...(host.password ? { password: host.password } : {}),
localCredential: () => readDesktopManagedLocalCredential(connection), localCredential: () => readDesktopManagedLocalCredential(connection),
} satisfies Omit<DaemonClientConfig, "url">; } satisfies Omit<DaemonClientConfig, "url">;
@@ -668,6 +670,7 @@ function createDefaultDeps(
), ),
reconnect: { enabled: false }, reconnect: { enabled: false },
appVersion: resolveAppVersion() ?? undefined, appVersion: resolveAppVersion() ?? undefined,
createPluginClientBundleStore: createHostPluginClientBundleStore,
}); });
try { try {
await client.connect(); await client.connect();
@@ -684,6 +687,7 @@ function createDefaultDeps(
...(timeoutMs !== undefined ? { timeoutMs } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities, capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
createPluginClientBundleStore: createHostPluginClientBundleStore,
}); });
}, },
getClientId: () => getOrCreateClientId(), getClientId: () => getOrCreateClientId(),
@@ -12,6 +12,69 @@ test("settings restart completes when a replacement worker is observed without a
expect(pid).toBe(11); expect(pid).toBe(11);
}); });
test("settings restart keeps terminals by default and closes them only when asked", async () => {
let pid = 10;
const requests: Array<{ killTerminals: boolean }> = [];
const deps = {
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
restartServer: async (_reason: string, options: { killTerminals: boolean }) => {
requests.push(options);
pid += 1;
},
};
await restartDaemonFromSettings("daemon", "settings", deps);
await restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true });
expect(requests).toEqual([{ killTerminals: false }, { killTerminals: true }]);
});
test("a close-terminals restart completes when the replacement reports every terminal closed", async () => {
let pid = 10;
await restartDaemonFromSettings(
"daemon",
"settings",
{
getStatus: async () => ({
pid,
version: "1.0.0",
serverId: "daemon",
terminalCleanupUnconfirmed: [],
}),
restartServer: async () => {
pid = 11;
},
},
{ killTerminals: true },
);
expect(pid).toBe(11);
});
test("a close-terminals restart fails with the processes the replacement could not confirm closed", async () => {
let pid = 10;
const leftover = {
pid: 4242,
role: "terminal program",
reason: "still alive",
detail: "survived SIGKILL",
};
const deps = {
getStatus: async () => ({
pid,
version: "1.0.0",
serverId: "daemon",
terminalCleanupUnconfirmed: pid === 10 ? [] : [leftover],
}),
restartServer: async () => {
pid += 1;
},
};
await expect(
restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true }),
).rejects.toThrow("pid 4242 (terminal program): still alive (survived SIGKILL)");
// Keeping terminals asks nothing of the cleanup, so a leftover does not fail it.
await restartDaemonFromSettings("daemon", "settings", deps);
});
test("a status permission failure prevents the restart request", async () => { test("a status permission failure prevents the restart request", async () => {
let restarted = false; let restarted = false;
await expect( await expect(
@@ -1,35 +1,61 @@
interface UnconfirmedTerminalProcess {
pid: number;
role: string;
reason: string;
detail: string;
}
interface WorkerStatus { interface WorkerStatus {
pid: number; pid: number;
serverId: string; serverId: string;
version: string | null; version: string | null;
// Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup to report.
terminalCleanupUnconfirmed?: UnconfirmedTerminalProcess[];
} }
interface StatusReader { interface StatusReader {
getStatus: () => Promise<WorkerStatus>; getStatus: () => Promise<WorkerStatus>;
} }
export interface SettingsDaemonRestartDeps extends StatusReader { export interface SettingsDaemonRestartDeps extends StatusReader {
restartServer: (reason: string) => Promise<unknown>; restartServer: (reason: string, options: { killTerminals: boolean }) => Promise<unknown>;
} }
/** Acknowledgment starts the wait; only a different ready worker completes it. */ /**
* Acknowledgment starts the wait; only a different ready worker completes it. Terminals survive
* the restart unless `killTerminals` is set (daemons advertising terminalsSurviveRestart); then
* the replacement must also report every terminal process gone.
*/
export async function restartDaemonFromSettings( export async function restartDaemonFromSettings(
hostServerId: string, hostServerId: string,
reason: string, reason: string,
deps: SettingsDaemonRestartDeps, deps: SettingsDaemonRestartDeps,
options: { killTerminals: boolean } = { killTerminals: false },
): Promise<void> { ): Promise<void> {
const previous = await readSelectedWorker(hostServerId, deps); const previous = await readSelectedWorker(hostServerId, deps);
let acknowledged = false; let acknowledged = false;
try { try {
await deps.restartServer(reason); await deps.restartServer(reason, options);
acknowledged = true; acknowledged = true;
} catch (error) { } catch (error) {
if (!isReconnectFailure(error)) throw error; if (!isReconnectFailure(error)) throw error;
} }
let replacement: WorkerStatus;
try { try {
await observeReplacement(previous, deps); replacement = await observeReplacement(previous, deps);
} catch (error) { } catch (error) {
throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error }); throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error });
} }
const unconfirmed = replacement.terminalCleanupUnconfirmed ?? [];
if (options.killTerminals && unconfirmed.length > 0) {
throw new Error(
[
"The daemon restarted, but closing its terminals was not confirmed; these processes may still be running:",
...unconfirmed.map(
(entry) => `pid ${entry.pid} (${entry.role}): ${entry.reason} (${entry.detail})`,
),
"Stopping the daemon retries the cleanup.",
].join("\n"),
);
}
} }
export async function updateDaemonFromSettings( export async function updateDaemonFromSettings(
+111 -63
View File
@@ -65,6 +65,7 @@ import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { useProviderIcon } from "@/components/provider-icons"; import { useProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card"; import { BrowserToolsOptInCard } from "./browser-tools-card";
import { RestartRecoveryCard } from "./restart-recovery-card";
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card"; import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
@@ -292,6 +293,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
<InjectPaseoToolsCard serverId={serverId} /> <InjectPaseoToolsCard serverId={serverId} />
<BrowserToolsOptInCard serverId={serverId} /> <BrowserToolsOptInCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} /> <AppendSystemPromptCard serverId={serverId} />
<RestartRecoveryCard serverId={serverId} />
<IdleRuntimeCleanupCard serverId={serverId} /> <IdleRuntimeCleanupCard serverId={serverId} />
</SettingsSection> </SettingsSection>
) : ( ) : (
@@ -565,7 +567,15 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
const isConnected = useHostRuntimeIsConnected(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore(); const runtime = getHostRuntimeStore();
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
// Rendered in the card, not an Alert.alert: react-native-web's Alert is a no-op, and a failed
// close-terminals restart must name the processes left running until the user retries.
const [restartError, setRestartError] = useState<string | null>(null);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
const terminalsSurviveRestart = useSessionStore(
(state) =>
state.sessions[host.serverId]?.serverInfo?.features?.terminalsSurviveRestart === true,
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -578,71 +588,80 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
[host.serverId, runtime], [host.serverId, runtime],
); );
const waitForDaemonRestart = useCallback( const waitForDaemonRestart = useCallback(async (request: Promise<void>) => {
async (request: Promise<void>) => { try {
try { await request;
await request; } catch (error) {
} catch (error) { console.error("[HostPage] Daemon restart failed", error);
Alert.alert( if (isMountedRef.current) {
t("settings.host.daemon.restart.requestFailedTitle"), setRestartError(error instanceof Error ? error.message : String(error));
error instanceof Error ? error.message : String(error),
);
} finally {
if (isMountedRef.current) setIsRestarting(false);
} }
} finally {
if (isMountedRef.current) setIsRestarting(false);
}
}, []);
const startRestart = useCallback(
(killTerminals: boolean) => {
if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.restart.unavailableTitle"),
t("settings.host.daemon.restart.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.restart.offlineTitle"),
t("settings.host.daemon.restart.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
message: killTerminals
? t("settings.host.daemon.restart.closeTerminalsConfirmMessage")
: t("settings.host.daemon.restart.confirmMessage"),
confirmLabel: killTerminals
? t("settings.host.daemon.restart.closeTerminals")
: t("settings.host.daemon.restart.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
})
.then((confirmed) => {
if (!confirmed) return;
setRestartError(null);
setIsRestarting(true);
const restartRequest = restartDaemonFromSettings(
host.serverId,
`settings_daemon_restart_${host.serverId}`,
{
restartServer: (reason, options) =>
daemonClient.restartServer(reason, undefined, options),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus()),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
},
{ killTerminals },
);
void waitForDaemonRestart(restartRequest);
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
if (isMountedRef.current) {
setRestartError(t("settings.host.daemon.restart.dialogFailedMessage"));
}
});
}, },
[t], [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart],
); );
const handleRestart = useCallback(() => { const handleRestart = useCallback(() => startRestart(false), [startRestart]);
if (!daemonClient) { const handleRestartAndCloseTerminals = useCallback(() => startRestart(true), [startRestart]);
Alert.alert(
t("settings.host.daemon.restart.unavailableTitle"),
t("settings.host.daemon.restart.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.restart.offlineTitle"),
t("settings.host.daemon.restart.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
message: t("settings.host.daemon.restart.confirmMessage"),
confirmLabel: t("settings.host.daemon.restart.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
})
.then((confirmed) => {
if (!confirmed) return;
setIsRestarting(true);
const restartRequest = restartDaemonFromSettings(
host.serverId,
`settings_daemon_restart_${host.serverId}`,
{
restartServer: (reason) => daemonClient.restartServer(reason),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus()),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
},
);
void waitForDaemonRestart(restartRequest);
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
Alert.alert(
t("settings.host.daemon.restart.requestFailedTitle"),
t("settings.host.daemon.restart.dialogFailedMessage"),
);
});
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
return ( return (
<View style={settingsStyles.card} testID="host-page-restart-card"> <View style={settingsStyles.card} testID="host-page-restart-card">
@@ -664,6 +683,35 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
: t("settings.host.daemon.restart.confirm")} : t("settings.host.daemon.restart.confirm")}
</Button> </Button>
</View> </View>
{terminalsSurviveRestart ? (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowHint}>
{t("settings.host.daemon.restart.closeTerminalsHint")}
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={handleRestartAndCloseTerminals}
disabled={isRestarting || !daemonClient || !isConnected}
testID="host-page-restart-close-terminals-button"
>
{t("settings.host.daemon.restart.closeTerminals")}
</Button>
</View>
) : null}
{restartError ? (
<View style={styles.cardAlert}>
<InlineAlert
size="sm"
variant="error"
title={t("settings.host.daemon.restart.requestFailedTitle")}
description={restartError}
testID="host-page-restart-error"
/>
</View>
) : null}
</View> </View>
); );
} }
@@ -848,7 +896,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
/> />
) : null} ) : null}
{updateState.status === "failed" ? ( {updateState.status === "failed" ? (
<View style={styles.updateFailure}> <View style={styles.cardAlert}>
<InlineAlert <InlineAlert
size="sm" size="sm"
variant="error" variant="error"
@@ -1702,7 +1750,7 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
})); }));
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
updateFailure: { cardAlert: {
marginHorizontal: theme.spacing[4], marginHorizontal: theme.spacing[4],
marginBottom: theme.spacing[4], marginBottom: theme.spacing[4],
}, },
@@ -0,0 +1,199 @@
import { useCallback, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
const DEFAULT_RESUME_PROMPT = "Resume working.";
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
export function RestartRecoveryCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useHostFeature(serverId, "resumeOnStartupSetting");
const { config, patchConfig } = useDaemonConfig(serverId);
const [isEditing, setIsEditing] = useState(false);
const mutation = useMutation({
mutationFn: async (next: { resumeOnStartup?: boolean; resumePrompt?: string }) => {
const result = await patchConfig(next);
if (!result) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return result;
},
});
const handleValueChange = useCallback(
(next: boolean) => {
mutation.mutate({ resumeOnStartup: next });
},
[mutation],
);
const handleOpen = useCallback(() => {
mutation.reset();
setIsEditing(true);
}, [mutation]);
const handleClose = useCallback(() => {
if (mutation.isPending) return;
mutation.reset();
setIsEditing(false);
}, [mutation]);
const handleSave = useCallback(
(prompt: string) => {
mutation.mutate({ resumePrompt: prompt }, { onSuccess: () => setIsEditing(false) });
},
[mutation],
);
if (!isConnected || !isSupported || !config) return null;
const isEnabled = config.resumeOnStartup !== false;
return (
<>
<View style={settingsStyles.card} testID="host-page-restart-recovery-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<Text style={settingsStyles.rowTitle}>
{t("settings.host.agents.resumeOnStartup.title")}
</Text>
<SettingsInfoTip
title={t("settings.host.agents.resumeOnStartup.title")}
info={t("settings.host.agents.resumeOnStartup.info")}
/>
</View>
<Text style={settingsStyles.rowHint}>
{t("settings.host.agents.resumeOnStartup.hint")}
</Text>
{mutation.error && !isEditing ? (
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
{mutation.error.message}
</Text>
) : null}
</View>
<View style={styles.trailing}>
<Button
variant="outline"
size="sm"
onPress={handleOpen}
disabled={!isEnabled || mutation.isPending}
accessibilityLabel={t("settings.host.agents.resumeOnStartup.editAccessibilityLabel")}
testID="host-page-restart-recovery-edit"
>
{t("settings.host.agents.resumeOnStartup.edit")}
</Button>
<Switch
value={isEnabled}
onValueChange={handleValueChange}
disabled={mutation.isPending}
accessibilityLabel={t("settings.host.agents.resumeOnStartup.title")}
testID="host-page-restart-recovery-switch"
/>
</View>
</View>
</View>
{isEditing ? (
<ResumePromptSheet
initialPrompt={config.resumePrompt ?? DEFAULT_RESUME_PROMPT}
isSaving={mutation.isPending}
error={mutation.error?.message ?? null}
onSave={handleSave}
onClose={handleClose}
/>
) : null}
</>
);
}
function ResumePromptSheet({
initialPrompt,
isSaving,
error,
onSave,
onClose,
}: {
initialPrompt: string;
isSaving: boolean;
error: string | null;
onSave: (prompt: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(initialPrompt);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.host.agents.resumeOnStartup.prompt") }),
[t],
);
const canSave = draft !== initialPrompt && draft.trim().length > 0 && !isSaving;
const handleSave = useCallback(() => onSave(draft), [draft, onSave]);
return (
<AdaptiveModalSheet
header={header}
visible
onClose={onClose}
testID="host-page-restart-recovery-sheet"
desktopMaxWidth={560}
>
<SettingsTextAreaCard
testID="host-page-restart-recovery-prompt-input"
accessibilityLabel={t("settings.host.agents.resumeOnStartup.prompt")}
value={draft}
onChangeText={setDraft}
placeholder={DEFAULT_RESUME_PROMPT}
/>
{error ? (
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
{error}
</Text>
) : null}
<View style={styles.sheetActions}>
<Button variant="ghost" size="sm" onPress={onClose} disabled={isSaving}>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
onPress={handleSave}
disabled={!canSave}
testID="host-page-restart-recovery-save"
>
{t("common.actions.save")}
</Button>
</View>
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
sheetActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
}));
+16
View File
@@ -371,6 +371,22 @@ describe("viewed timeline persistence", () => {
]); ]);
}); });
it("preparing an already painted cache again keeps history out of the live head", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined },
prepareAgent: async () => undefined,
});
await replica.prepare(AGENT_ID);
const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID);
expect(painted).toEqual(cachedTimeline().items);
await replica.prepare(AGENT_ID);
const session = useSessionStore.getState().sessions[SERVER_ID]!;
expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]);
expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted);
});
it("reconciles a live head that overlaps the cached canonical timeline", async () => { it("reconciles a live head that overlaps the cached canonical timeline", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null); useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void; let release!: (value: CachedTimeline) => void;
@@ -109,6 +109,15 @@ class TimelineReplicaOwner implements TimelineReplica {
async prepare(agentId: string): Promise<void> { async prepare(agentId: string): Promise<void> {
const existing = this.preparations.get(agentId); const existing = this.preparations.get(agentId);
if (existing) return existing; if (existing) return existing;
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[this.serverId],
agentId,
);
// A route and the visible-chat owner can both prepare before catch-up finishes.
// Repainting that same cache would treat its history as a live head.
if (timeline.status !== "cold" && this.cachedRanges.has(agentId)) {
return;
}
const preparation = this.load(agentId).finally(() => { const preparation = this.load(agentId).finally(() => {
if (this.preparations.get(agentId) === preparation) { if (this.preparations.get(agentId) === preparation) {
this.preparations.delete(agentId); this.preparations.delete(agentId);
+28 -71
View File
@@ -1,10 +1,16 @@
import { useMemo } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format"; import {
import type { UsageBalance, UsageTone } from "./types"; formatAmount,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
} from "./format";
import { UsageMeter } from "./meter";
import { UsageResetLine } from "./reset-line";
import type { UsageBalance } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick"; import { useRelativeTimeTick } from "./use-relative-time-tick";
interface ResolvedBalance { interface ResolvedBalance {
@@ -41,19 +47,6 @@ function resolveBalance({
return { amountText: "—", usedPct: null }; return { amountText: "—", usedPct: null };
} }
function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
export function UsageBalanceBar({ export function UsageBalanceBar({
balance, balance,
retained = false, retained = false,
@@ -66,11 +59,7 @@ export function UsageBalanceBar({
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage }); const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
const tone = balance.tone ?? "default"; const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t); const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
const resetTime = formatResetTime(balance.resetsAt, now, t);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
[usedPct, tone],
);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -78,22 +67,21 @@ export function UsageBalanceBar({
<Text style={styles.label} numberOfLines={1}> <Text style={styles.label} numberOfLines={1}>
{formatProviderUsageLabel(balance.id, balance.label, t)} {formatProviderUsageLabel(balance.id, balance.label, t)}
</Text> </Text>
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}> <Text
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail"> style={styles.amount}
{amountText} numberOfLines={1}
</Text> ellipsizeMode="tail"
{resetLabel ? ( testID={`provider-usage-balance-${balance.id}-value`}
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}> >
{resetLabel} {amountText}
</Text> </Text>
) : null}
</View>
</View> </View>
{usedPct != null ? ( {usedPct != null ? <UsageMeter percent={usedPct} tone={tone} /> : null}
<View style={styles.track}> <UsageResetLine
<View style={fillStyle} /> countdown={resetLabel}
</View> exactTime={resetTime}
) : null} testID={`provider-usage-balance-${balance.id}-reset`}
/>
</View> </View>
); );
} }
@@ -110,44 +98,13 @@ const styles = StyleSheet.create((theme) => ({
}, },
label: { label: {
flexShrink: 1, flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
alignItems: "flex-end",
},
amount: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
reset: { amount: {
color: theme.colors.foregroundMuted, flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
textAlign: "right",
},
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
fill: {
height: 4,
borderRadius: 2,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
}, },
})); }));
+67 -2
View File
@@ -7,6 +7,7 @@ import {
formatPct, formatPct,
formatProviderUsageLabel, formatProviderUsageLabel,
formatResetLabel, formatResetLabel,
formatResetTime,
formatRunsOutLabel, formatRunsOutLabel,
} from "./format"; } from "./format";
@@ -36,8 +37,8 @@ describe("formatAmount", () => {
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString(); const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
expect(formatPct(7, "en")).toBe("7%"); expect(formatPct(7, "en")).toBe("7%");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h"); expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h"); expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h");
await i18n.changeLanguage("ja"); await i18n.changeLanguage("ja");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット"); expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
@@ -94,6 +95,70 @@ describe("formatAmount", () => {
}); });
}); });
describe("reset countdown", () => {
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const countdown = (ms: number) => formatResetLabel(new Date(NOW + ms).toISOString(), false, NOW);
beforeEach(async () => {
await i18n.changeLanguage("en");
});
it("keeps counting down inside a day instead of sitting on the rounded-down day", () => {
expect(countdown(2 * DAY + 23 * HOUR + 10 * MINUTE)).toBe("resets in 2d 23h");
expect(countdown(2 * DAY + 22 * HOUR + 10 * MINUTE)).toBe("resets in 2d 22h");
});
it("says two units, dropping a unit that is zero", () => {
expect(countdown(DAY + 30 * MINUTE)).toBe("resets in 1d");
expect(countdown(4 * HOUR + 59 * MINUTE)).toBe("resets in 4h 59m");
expect(countdown(HOUR)).toBe("resets in 1h");
expect(countdown(37 * MINUTE)).toBe("resets in 37m");
expect(countdown(59 * 1000)).toBe("resets in 0m");
});
it("joins the at-risk countdown the same way", () => {
const iso = new Date(NOW + 3 * HOUR + 5 * MINUTE).toISOString();
expect(formatRunsOutLabel(iso, NOW)).toBe("runs out in 3h 5m");
});
});
describe("exact reset time", () => {
// Local-time dates, so the thresholds hold in any timezone the tests run in.
const now = new Date(2026, 9, 29, 12, 0).getTime();
const at = (...args: [number, number, number, number, number]) =>
formatResetTime(new Date(...args).toISOString(), now)?.replace(/\s/g, " ");
beforeEach(async () => {
await i18n.changeLanguage("en");
});
it("shows only the time within 24 hours", () => {
expect(at(2026, 9, 29, 15, 0)).toMatch(/^(3:00 PM|15:00)$/);
expect(at(2026, 9, 30, 11, 59)).toMatch(/^(11:59 AM|11:59)$/);
});
it("adds the weekday within 7 days", () => {
expect(at(2026, 9, 30, 12, 0)).toMatch(/^Fri (12:00 PM|12:00)$/);
expect(at(2026, 10, 5, 11, 59)).toMatch(/^Thu (11:59 AM|11:59)$/);
});
it("adds the date after 7 days", () => {
expect(at(2026, 10, 5, 12, 0)).toMatch(/^Nov 5, (12:00 PM|12:00)$/);
});
it("names the weekday in the app language", async () => {
await i18n.changeLanguage("fr");
expect(at(2026, 9, 31, 15, 0)).toMatch(/^sam\.? /);
});
it("says nothing for a time that has passed", () => {
expect(formatResetTime(new Date(now - 1).toISOString(), now)).toBeNull();
expect(formatResetTime(null, now)).toBeNull();
});
});
it("retained reset times that have passed do not claim the quota is resetting", () => { it("retained reset times that have passed do not claim the quota is resetting", () => {
const past = "2000-01-01T00:00:00.000Z"; const past = "2000-01-01T00:00:00.000Z";
expect(formatResetLabel(past, true)).toBe("Reset time passed"); expect(formatResetLabel(past, true)).toBe("Reset time passed");
+68 -35
View File
@@ -1,5 +1,6 @@
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { formatTimeOfDay } from "@/utils/time";
import type { UsageDisplayAs } from "./preferences"; import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types"; import type { UsageBalanceUnit } from "./types";
@@ -50,7 +51,17 @@ export function formatDisplayPct(
: t("providerUsage.values.remaining", { amount: percentage }); : t("providerUsage.values.remaining", { amount: percentage });
} }
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number }; type DurationUnit = "minutes" | "hours" | "days";
interface DurationPart {
unit: DurationUnit;
count: number;
}
/** Two units, floored, so a countdown keeps moving: "2d 23h", "4h 59m", "37m". */
type RelativeDuration = { kind: "now" } | { kind: "remaining"; parts: DurationPart[] };
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
function formatCount(value: number): string { function formatCount(value: number): string {
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value); return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
@@ -59,35 +70,34 @@ function formatCount(value: number): string {
function relativeDuration(iso: string, now: number): RelativeDuration | null { function relativeDuration(iso: string, now: number): RelativeDuration | null {
const diffMs = new Date(iso).getTime() - now; const diffMs = new Date(iso).getTime() - now;
if (!Number.isFinite(diffMs)) return null; if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return { unit: "now" }; if (diffMs <= 0) return { kind: "now" };
const diffMinutes = Math.floor(diffMs / 60_000); const days = Math.floor(diffMs / DAY_MS);
const diffHours = Math.floor(diffMinutes / 60); const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS);
const diffDays = Math.floor(diffHours / 24); const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS);
if (diffDays > 0) return { unit: "days", count: diffDays }; if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours });
if (diffHours > 0) return { unit: "hours", count: diffHours }; if (hours > 0)
return { unit: "minutes", count: diffMinutes }; return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes });
return remaining({ unit: "minutes", count: minutes });
} }
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null { /** The major unit, plus the minor one unless it is zero. */
switch (duration.unit) { function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration {
case "now": return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] };
return null; }
case "days":
return t("providerUsage.duration.days", { function formatDurationPart(part: DurationPart, t: TFunction): string {
count: duration.count, return t(`providerUsage.duration.${part.unit}`, {
value: formatCount(duration.count), count: part.count,
}); value: formatCount(part.count),
case "hours": });
return t("providerUsage.duration.hours", { }
count: duration.count,
value: formatCount(duration.count), function formatRelativeDuration(
}); duration: Extract<RelativeDuration, { kind: "remaining" }>,
case "minutes": t: TFunction,
return t("providerUsage.duration.minutes", { ): string {
count: duration.count, const [first, second] = duration.parts.map((part) => formatDurationPart(part, t));
value: formatCount(duration.count), return second ? t("providerUsage.duration.pair", { first, second }) : first;
});
}
} }
export function formatResetLabel( export function formatResetLabel(
@@ -99,10 +109,34 @@ export function formatResetLabel(
if (!iso) return null; if (!iso) return null;
const rel = relativeDuration(iso, now); const rel = relativeDuration(iso, now);
if (!rel) return null; if (!rel) return null;
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed"); if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed");
if (rel.unit === "now") return t("providerUsage.timing.resettingNow"); if (rel.kind === "now") return t("providerUsage.timing.resettingNow");
const duration = formatRelativeDuration(rel, t); return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) });
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null; }
/**
* When a window resets on the clock: "3:00 PM" within a day, "Fri 3:00 PM" within a week,
* "Oct 31, 3:00 PM" after that. Names follow the app language; 12/24h follows the device.
* Nothing for a time that has passed, where the countdown already says so.
*/
export function formatResetTime(
iso: string | null | undefined,
now = Date.now(),
t: TFunction = i18n.t,
): string | null {
if (!iso) return null;
const at = new Date(iso);
const diffMs = at.getTime() - now;
if (!Number.isFinite(diffMs) || diffMs <= 0) return null;
const time = formatTimeOfDay(at);
const locale = i18n.resolvedLanguage;
if (diffMs < DAY_MS) return time;
if (diffMs < 7 * DAY_MS) {
const weekday = at.toLocaleDateString(locale, { weekday: "short" });
return t("providerUsage.timing.resetsAtWeekday", { weekday, time });
}
const date = at.toLocaleDateString(locale, { month: "short", day: "numeric" });
return t("providerUsage.timing.resetsAtDate", { date, time });
} }
export function formatRunsOutLabel( export function formatRunsOutLabel(
@@ -113,9 +147,8 @@ export function formatRunsOutLabel(
if (!iso) return null; if (!iso) return null;
const rel = relativeDuration(iso, now); const rel = relativeDuration(iso, now);
if (!rel) return null; if (!rel) return null;
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow"); if (rel.kind === "now") return t("providerUsage.timing.runsOutNow");
const duration = formatRelativeDuration(rel, t); return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) });
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
} }
export function formatAgo( export function formatAgo(
+2 -2
View File
@@ -46,8 +46,8 @@ export function UsageMeter({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
track: { track: {
height: 4, height: 6,
borderRadius: 2, borderRadius: 3,
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface3,
overflow: "hidden", overflow: "hidden",
}, },
+48
View File
@@ -0,0 +1,48 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* The reset data of a window or balance: the countdown first, then the exact time. When the line
* is too narrow the exact time gives way (truncates) and the countdown stays whole.
*/
export function UsageResetLine({
countdown,
exactTime,
atRisk = false,
testID,
}: {
countdown: string | null;
exactTime: string | null;
/** The countdown is a runs-out warning rather than a reset. */
atRisk?: boolean;
testID?: string;
}) {
if (!countdown) return null;
return (
<View style={styles.line} testID={testID}>
<Text style={atRisk ? styles.atRisk : styles.text}>{countdown}</Text>
{exactTime ? (
<Text style={[styles.text, styles.exactTime]} numberOfLines={1} ellipsizeMode="tail">
{`\u00a0·\u00a0${exactTime}`}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
line: { flexDirection: "row", alignItems: "center" },
text: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
atRisk: {
flexShrink: 0,
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
exactTime: { flexShrink: 1 },
}));
+1 -10
View File
@@ -192,12 +192,7 @@ function PinnedUsageLine({
<Fragment key={window.key}> <Fragment key={window.key}>
{layout === "meters" ? ( {layout === "meters" ? (
<View style={[styles.meterSlot, leadStyle]}> <View style={[styles.meterSlot, leadStyle]}>
<UsageMeter <UsageMeter percent={window.percent} tone={window.tone} quiet />
percent={window.percent}
tone={window.tone}
quiet
style={styles.meter}
/>
</View> </View>
) : null} ) : null}
<Text <Text
@@ -269,10 +264,6 @@ const styles = StyleSheet.create((theme) => ({
minWidth: MIN_METER_WIDTH, minWidth: MIN_METER_WIDTH,
marginRight: METER_GAP, marginRight: METER_GAP,
}, },
meter: {
height: 6,
borderRadius: 3,
},
percent: { percent: {
flexShrink: 0, flexShrink: 0,
color: theme.colors.foreground, color: theme.colors.foreground,
+38 -86
View File
@@ -1,29 +1,24 @@
import { Pin } from "lucide-react-native"; import { Pin } from "lucide-react-native";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { import {
formatDisplayPct, formatDisplayPct,
formatProviderUsageLabel, formatProviderUsageLabel,
formatResetLabel, formatResetLabel,
formatResetTime,
formatRunsOutLabel, formatRunsOutLabel,
} from "./format"; } from "./format";
import { UsageMeter } from "./meter"; import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model"; import { displayPercent, usageWindowRowLabel } from "./model";
import { UsageResetLine } from "./reset-line";
import type { UsageDisplayAs } from "./preferences"; import type { UsageDisplayAs } from "./preferences";
import { windowTone } from "./tone"; import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types"; import type { UsageTone, UsageWindow } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick"; import { useRelativeTimeTick } from "./use-relative-time-tick";
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
// so a hover never reads as the selection. Pinned rows do not react to hover.
function highlightStyle(pinned: boolean, hovered: boolean) {
if (pinned) return styles.highlightPinned;
return hovered ? styles.highlightHovered : styles.highlightNone;
}
export function UsageWindowBar({ export function UsageWindowBar({
window, window,
displayAs, displayAs,
@@ -54,9 +49,12 @@ export function UsageWindowBar({
window.runsOutAt != null && window.runsOutAt != null &&
window.shortfallPct != null && window.shortfallPct != null &&
(!retained || Date.parse(window.runsOutAt) > now); (!retained || Date.parse(window.runsOutAt) > now);
const trailing = isAtRisk const countdown = isAtRisk
? formatRunsOutLabel(window.runsOutAt, now, t) ? formatRunsOutLabel(window.runsOutAt, now, t)
: formatResetLabel(window.resetsAt, retained, now, t); : formatResetLabel(window.resetsAt, retained, now, t);
// The exact time belongs to the reset; beside a runs-out warning it would read as the moment
// the quota runs out.
const exactTime = isAtRisk ? null : formatResetTime(window.resetsAt, now, t);
const value = const value =
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—"; shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
@@ -64,60 +62,57 @@ export function UsageWindowBar({
const content = { const content = {
label: formatProviderUsageLabel(window.id, window.label, t), label: formatProviderUsageLabel(window.id, window.label, t),
value, value,
trailing, countdown,
exactTime,
isAtRisk, isAtRisk,
percent: shownPct ?? 0, percent: shownPct ?? 0,
tone, tone,
pinned, pinned,
}; };
// Same padding as the pinnable row, so bars line up in both modes.
if (!pinnable) { if (!pinnable) {
return ( return (
<View style={styles.row}> <View style={styles.row}>
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} /> <WindowRowContent {...content} pinnable={false} />
</View> </View>
); );
} }
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same // The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it.
// padding so toggling only changes the background.
return ( return (
<Pressable <Pressable
onPress={onTogglePin} onPress={onTogglePin}
accessibilityRole="checkbox" accessibilityRole="checkbox"
accessibilityLabel={usageWindowRowLabel({ pinLabel, value, trailing })} accessibilityLabel={usageWindowRowLabel({
pinLabel,
value,
trailing: [countdown, exactTime].filter(Boolean).join(" · "),
})}
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
aria-checked={pinned} aria-checked={pinned}
style={styles.row} style={styles.row}
testID={pinTestID} testID={pinTestID}
> >
{({ hovered }: { hovered?: boolean }) => ( <WindowRowContent {...content} pinnable />
<WindowRowContent
{...content}
highlight={highlightStyle(pinned, Boolean(hovered))}
pinnable
/>
)}
</Pressable> </Pressable>
); );
} }
function WindowRowContent({ function WindowRowContent({
highlight,
label, label,
value, value,
trailing, countdown,
exactTime,
isAtRisk, isAtRisk,
percent, percent,
tone, tone,
pinnable, pinnable,
pinned, pinned,
}: { }: {
highlight: StyleProp<ViewStyle>;
label: string; label: string;
value: string; value: string;
trailing: string | null | undefined; countdown: string | null;
exactTime: string | null;
isAtRisk: boolean; isAtRisk: boolean;
percent: number; percent: number;
tone: UsageTone; tone: UsageTone;
@@ -126,24 +121,17 @@ function WindowRowContent({
}) { }) {
return ( return (
<> <>
<View style={highlight} pointerEvents="none" /> <View style={styles.labelRow}>
<View style={styles.contentRow}> <Text style={styles.label} numberOfLines={1}>
<View style={styles.windowContent}> {label}
<View style={styles.labelRow}> </Text>
<Text style={styles.label} numberOfLines={1}> <Text style={styles.value} numberOfLines={1}>
{label} {value}
</Text> </Text>
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
) : null}
</Text>
</View>
<UsageMeter percent={percent} tone={tone} />
</View>
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null} {pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
</View> </View>
<UsageMeter percent={percent} tone={tone} />
<UsageResetLine countdown={countdown} exactTime={exactTime} atRisk={isAtRisk} />
</> </>
); );
} }
@@ -153,9 +141,9 @@ const ThemedPin = withUnistyles(Pin);
function UsagePinGlyph({ pinned }: { pinned: boolean }) { function UsagePinGlyph({ pinned }: { pinned: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const iconMapping = useMemo( const iconMapping = useMemo(
() => (theme: { colors: { foregroundMuted: string } }) => ({ () => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted, color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted,
fill: pinned ? theme.colors.foregroundMuted : "none", fill: pinned ? theme.colors.foreground : "none",
}), }),
[pinned], [pinned],
); );
@@ -179,59 +167,23 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, row: { gap: 3, paddingVertical: theme.spacing[1.5] },
windowContent: { flex: 1, gap: 3 },
pin: { width: 12, alignItems: "center" }, pin: { width: 12, alignItems: "center" },
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm }, tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
row: {
gap: 3,
// The highlight bleeds into the card padding so the label and bar stay on the card's rail.
marginHorizontal: -theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
// Its own stacking context, so the highlight layer paints above the card and below the text.
zIndex: 0,
},
highlightNone: {
display: "none",
},
highlightPinned: {
...StyleSheet.absoluteFill,
zIndex: -1,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
// The pinned surface at half strength: a separate layer, so the text keeps full opacity.
highlightHovered: {
...StyleSheet.absoluteFill,
zIndex: -1,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: theme.opacity[50],
},
labelRow: { labelRow: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
label: { label: {
flex: 1, flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
reset: { value: {
color: theme.colors.foregroundMuted, flexShrink: 0,
fontWeight: theme.fontWeight.normal, color: theme.colors.foreground,
}, fontSize: theme.fontSize.sm,
atRisk: {
color: theme.colors.statusDanger,
fontWeight: theme.fontWeight.normal,
}, },
})); }));
@@ -129,6 +129,23 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined(); expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
}); });
it("keeps the bundle cache factory on a probe client that the host runtime can adopt", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const createPluginClientBundleStore = () => ({
async load() {
return [];
},
async replace() {},
});
const { client } = await connectToDaemon(
{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" },
{ createPluginClientBundleStore },
probe.deps,
);
expect(client.config.createPluginClientBundleStore).toBe(createPluginClientBundleStore);
await client.close();
});
it("encodes the local socket target into the client config", async () => { it("encodes the local socket target into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon( const result = await connectToDaemon(
@@ -132,12 +132,13 @@ function resolveConnectionCredentials(
export async function buildClientConfig( export async function buildClientConfig(
connection: HostConnection, connection: HostConnection,
serverId?: string, serverId?: string,
options?: { options: {
capabilities?: DaemonClientConfig["capabilities"]; capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"]; trace?: DaemonClientConfig["trace"];
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
password?: string; password?: string;
localCredential?: DaemonClientConfig["localCredential"]; localCredential?: DaemonClientConfig["localCredential"];
}, } = {},
deps: Pick< deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>, DaemonConnectionDependencies<DaemonProbeClient>,
| "getClientId" | "getClientId"
@@ -157,8 +158,9 @@ export async function buildClientConfig(
suppressSendErrors: true, suppressSendErrors: true,
reconnect: { enabled: false }, reconnect: { enabled: false },
...resolveConnectionCredentials(connection, options), ...resolveConnectionCredentials(connection, options),
...(options?.capabilities ? { capabilities: options.capabilities } : {}), ...(options.capabilities ? { capabilities: options.capabilities } : {}),
...(options?.trace ? { trace: options.trace } : {}), ...(options.trace ? { trace: options.trace } : {}),
createPluginClientBundleStore: options.createPluginClientBundleStore,
...((connection.type === "directSocket" || connection.type === "directPipe") && ...((connection.type === "directSocket" || connection.type === "directPipe") &&
desktopTransportFactory desktopTransportFactory
? { transportFactory: desktopTransportFactory } ? { transportFactory: desktopTransportFactory }
@@ -288,6 +290,7 @@ interface ProbeOptions {
localCredential?: DaemonClientConfig["localCredential"]; localCredential?: DaemonClientConfig["localCredential"];
capabilities?: DaemonClientConfig["capabilities"]; capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"]; trace?: DaemonClientConfig["trace"];
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
} }
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number { function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
+6 -1
View File
@@ -125,6 +125,11 @@ function getTimeFormatter(): Intl.DateTimeFormat {
return cachedTimeFormatter; return cachedTimeFormatter;
} }
/** A clock time in the device's 12h/24h preference: "3:00 PM" or "15:00". */
export function formatTimeOfDay(date: Date): string {
return getTimeFormatter().format(date);
}
/** /**
* Format a chat-message timestamp for hover-revealed UI. * Format a chat-message timestamp for hover-revealed UI.
* - Same day: "10:11 PM" or "22:11" depending on user preference * - Same day: "10:11 PM" or "22:11" depending on user preference
@@ -132,7 +137,7 @@ function getTimeFormatter(): Intl.DateTimeFormat {
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM" * - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
*/ */
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string { export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
const time = getTimeFormatter().format(date); const time = formatTimeOfDay(date);
const daysAgo = localCalendarDaysBetween(date, now); const daysAgo = localCalendarDaysBetween(date, now);
if (daysAgo === 0) { if (daysAgo === 0) {
+25 -1
View File
@@ -3,8 +3,11 @@ import {
readDaemonInstance, readDaemonInstance,
isSameDaemonInstance, isSameDaemonInstance,
DaemonInstanceError, DaemonInstanceError,
retryUnconfirmedTerminalCleanup,
TerminalCleanupNotConfirmedError,
} from "@getpaseo/server/daemon-control"; } from "@getpaseo/server/daemon-control";
import { setTimeout as delay } from "node:timers/promises"; import { setTimeout as delay } from "node:timers/promises";
import type { DaemonGetStatusResponse } from "@getpaseo/protocol/messages";
import { connectToDaemon } from "../../utils/client.js"; import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js"; import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
@@ -20,13 +23,30 @@ export function daemonRestartCommand(): Command {
), ),
) )
.option("--timeout <seconds>", "Replacement readiness deadline (default: 600)") .option("--timeout <seconds>", "Replacement readiness deadline (default: 600)")
.option("--kill-terminals", "Close every terminal instead of keeping them across the restart")
.action(withOutput(runRestartCommand)); .action(withOutput(runRestartCommand));
} }
type DaemonStatus = DaemonGetStatusResponse["payload"];
export async function runRestartCommand(options: CommandOptions, _command: Command) { export async function runRestartCommand(options: CommandOptions, _command: Command) {
const target = options.daemonTarget; const target = options.daemonTarget;
const deadline = Date.now() + parseTimeoutMs(options.timeout); const deadline = Date.now() + parseTimeoutMs(options.timeout);
const remaining = () => Math.max(1, deadline - Date.now()); const remaining = () => Math.max(1, deadline - Date.now());
const killTerminals = options.killTerminals === true;
// The supervisor reaps before the respawn and the replacement retries what that left;
// anything still named by a closing record survived both. A local caller retries once more
// itself; a remote one has only what the replacement reports.
const confirmTerminalsClosed = async (status: DaemonStatus) => {
if (!killTerminals) return;
if (target.kind === "instance") {
await retryUnconfirmedTerminalCleanup(target.home);
return;
}
// Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup.
const unconfirmed = status.terminalCleanupUnconfirmed ?? [];
if (unconfirmed.length > 0) throw new TerminalCleanupNotConfirmedError(unconfirmed);
};
const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
async function checkSupervisor() { async function checkSupervisor() {
if (target.kind !== "instance") return; if (target.kind !== "instance") return;
@@ -54,7 +74,10 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma
workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid; workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid;
await checkSupervisor(); await checkSupervisor();
try { try {
await client.restartServer("cli_restart", undefined, { timeout: remaining() }); await client.restartServer("cli_restart", undefined, {
timeout: remaining(),
killTerminals,
});
acknowledged = true; acknowledged = true;
} catch (error) { } catch (error) {
if (!isReconnectFailure(error)) throw error; if (!isReconnectFailure(error)) throw error;
@@ -77,6 +100,7 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma
const status = await replacement.getDaemonStatus({ timeout: remaining() }); const status = await replacement.getDaemonStatus({ timeout: remaining() });
if (status.pid !== workerPid) { if (status.pid !== workerPid) {
await checkSupervisor(); await checkSupervisor();
await confirmTerminalsClosed(status);
return { return {
type: "single" as const, type: "single" as const,
data: { data: {
@@ -103,6 +103,7 @@ async function probeDaemonStatus(
daemonNode: status.nodePath, daemonNode: status.nodePath,
providers: status.providers, providers: status.providers,
relay: status.relay, relay: status.relay,
...(status.terminalsUnavailable ? { terminals: status.terminalsUnavailable } : {}),
}; };
} }
} finally { } finally {
@@ -0,0 +1,110 @@
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { hostname, tmpdir } from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, describe, expect, test } from "vitest";
import { createStopCommandRunner, type StopCommandDeps } from "./stop.js";
// The daemon the gate is checked against: a connection whose server_info may or may not
// advertise terminalsSurviveRestart, and that records any shutdown it is asked for.
function createFakeDaemon(features: { terminalsSurviveRestart?: boolean } = {}) {
const shutdownRequests: Array<{ keepTerminals?: boolean }> = [];
const deps: StopCommandDeps = {
connectToDaemon: async () => ({
getLastServerInfoMessage: () => ({
status: "server_info",
serverId: "fake-daemon",
version: "0.11.0",
features,
}),
shutdownServer: async (options) => {
shutdownRequests.push({ keepTerminals: options?.keepTerminals });
return { status: "shutdown_requested", clientId: "fake-client", requestId: "fake-request" };
},
close: async () => undefined,
}),
};
return { deps, shutdownRequests };
}
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
});
function stop(options: Record<string, unknown>, deps: StopCommandDeps) {
return createStopCommandRunner(deps)(options as never, new Command("stop"));
}
// A home whose lock names a live stand-in supervisor, so the local stop path runs for real.
function homeWithRunningSupervisor(): { home: string; supervisor: ChildProcess } {
const home = mkdtempSync(path.join(tmpdir(), "paseo-stop-keep-"));
const supervisor = spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], {
stdio: "ignore",
});
writeFileSync(
path.join(home, "paseo.pid"),
JSON.stringify({
pid: supervisor.pid,
startedAt: new Date().toISOString(),
hostname: hostname(),
uid: process.getuid?.() ?? 0,
listen: "127.0.0.1:1",
}),
);
cleanups.push(() => {
supervisor.kill("SIGKILL");
rmSync(home, { recursive: true, force: true });
});
return { home, supervisor };
}
describe("daemon stop --keep-terminals", () => {
test("refuses an endpoint daemon that does not advertise terminalsSurviveRestart", async () => {
const daemon = createFakeDaemon();
await expect(
stop(
{ daemonTarget: { kind: "endpoint", host: "127.0.0.1:7777" }, keepTerminals: true },
daemon.deps,
),
).rejects.toMatchObject({ code: "TERMINALS_KEEP_UNSUPPORTED" });
expect(daemon.shutdownRequests).toEqual([]);
});
test.skipIf(process.platform === "win32")(
"refuses a local daemon without the capability and never signals its supervisor",
async () => {
const daemon = createFakeDaemon();
const { home, supervisor } = homeWithRunningSupervisor();
await expect(
stop({ daemonTarget: { kind: "instance", home }, keepTerminals: true }, daemon.deps),
).rejects.toMatchObject({ code: "TERMINALS_KEEP_UNSUPPORTED" });
expect(daemon.shutdownRequests).toEqual([]);
expect(supervisor.exitCode).toBeNull();
expect(supervisor.signalCode).toBeNull();
},
);
test("asks a capable daemon to keep its terminals", async () => {
const daemon = createFakeDaemon({ terminalsSurviveRestart: true });
await stop(
{ daemonTarget: { kind: "endpoint", host: "127.0.0.1:7777" }, keepTerminals: true },
daemon.deps,
);
expect(daemon.shutdownRequests).toEqual([{ keepTerminals: true }]);
});
test("rejects --force with --keep-terminals before contacting the daemon", async () => {
const daemon = createFakeDaemon({ terminalsSurviveRestart: true });
const { home } = homeWithRunningSupervisor();
await expect(
stop(
{ daemonTarget: { kind: "instance", home }, keepTerminals: true, force: true },
daemon.deps,
),
).rejects.toMatchObject({ code: "INVALID_OPTIONS" });
expect(daemon.shutdownRequests).toEqual([]);
});
});
+38 -4
View File
@@ -1,6 +1,10 @@
import { Command } from "commander"; import { Command } from "commander";
import {
requireTerminalKeepCapability,
type DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server/daemon-control"; import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server/daemon-control";
import { connectToDaemon } from "../../utils/client.js"; import { connectToDaemon, type ConnectOptions } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js"; import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { describeDaemonTarget } from "../../utils/daemon-target.js"; import { describeDaemonTarget } from "../../utils/daemon-target.js";
@@ -10,19 +14,43 @@ export function daemonStopCommand(): Command {
return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon")) return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon"))
.option("--timeout <seconds>", "Graceful exit deadline (default: 15)") .option("--timeout <seconds>", "Graceful exit deadline (default: 15)")
.option("--force", "Permit forced local process cleanup") .option("--force", "Permit forced local process cleanup")
.option(
"--keep-terminals",
"Leave terminals running; the next daemon started for this home takes them back",
)
.option("--kill-timeout <seconds>", "Forced exit deadline (default: 3)") .option("--kill-timeout <seconds>", "Forced exit deadline (default: 3)")
.action(withOutput(runStopCommand)); .action(withOutput(runStopCommand));
} }
export async function runStopCommand(options: CommandOptions, _command: Command) { // The daemon connection stop needs; stop.test.ts passes an in-memory adapter.
export type StopDaemonConnection = Pick<
DaemonClient,
"getLastServerInfoMessage" | "shutdownServer" | "close"
>;
export interface StopCommandDeps {
connectToDaemon(options: ConnectOptions): Promise<StopDaemonConnection>;
}
export const runStopCommand = createStopCommandRunner({ connectToDaemon });
export function createStopCommandRunner(deps: StopCommandDeps) {
return (options: CommandOptions, _command: Command) => runStop(options, deps);
}
async function runStop(options: CommandOptions, deps: StopCommandDeps) {
const target = options.daemonTarget; const target = options.daemonTarget;
const timeoutMs = parseTimeoutMs(options.timeout, 15_000); const timeoutMs = parseTimeoutMs(options.timeout, 15_000);
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
const remaining = () => Math.max(1, deadline - Date.now()); const remaining = () => Math.max(1, deadline - Date.now());
const keepTerminals = options.keepTerminals === true;
const requestShutdown = async (instance?: DaemonInstance) => { const requestShutdown = async (instance?: DaemonInstance) => {
const client = await connectToDaemon({ target, timeout: remaining(), instance }); const client = await deps.connectToDaemon({ target, timeout: remaining(), instance });
try { try {
await client.shutdownServer({ timeout: remaining() }); // Checked on the connection that carries the shutdown: a daemon without the capability
// would close the terminals it was asked to keep.
if (keepTerminals) requireTerminalKeepCapability(client, describeDaemonTarget(target));
await client.shutdownServer({ timeout: remaining(), keepTerminals });
} finally { } finally {
await client.close(); await client.close();
} }
@@ -32,6 +60,11 @@ export async function runStopCommand(options: CommandOptions, _command: Command)
code: "INVALID_OPTIONS", code: "INVALID_OPTIONS",
message: "--force requires a local --home; an endpoint gives no remote process authority.", message: "--force requires a local --home; an endpoint gives no remote process authority.",
}; };
if (options.force && keepTerminals)
throw {
code: "INVALID_OPTIONS",
message: "--force kills terminals; it cannot be combined with --keep-terminals.",
};
const result: { const result: {
action: string; action: string;
home?: string; home?: string;
@@ -44,6 +77,7 @@ export async function runStopCommand(options: CommandOptions, _command: Command)
? { ? {
...(await stopDaemonInstance(target.home, { ...(await stopDaemonInstance(target.home, {
force: options.force === true, force: options.force === true,
keepTerminals,
timeoutMs, timeoutMs,
killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000), killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000),
requestShutdown, requestShutdown,
@@ -0,0 +1,16 @@
#!/usr/bin/env npx tsx
import assert from "node:assert/strict";
import { startTestDaemon, runPaseoCli } from "./helpers/test-daemon.ts";
// Port zero leaves allocation to the listening daemon, with no reserve/release gap.
const daemon = await startTestDaemon({ port: 0 });
try {
assert(daemon.port > 0, "the helper returns the port the daemon actually bound");
assert.equal(daemon.wsUrl, `ws://127.0.0.1:${daemon.port}`);
const result = await runPaseoCli(daemon, ["agent", "ls", "--host", daemon.wsUrl, "--json"]);
assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), []);
} finally {
await daemon.stop();
}
@@ -0,0 +1,324 @@
#!/usr/bin/env npx tsx
/**
* Terminals survive `paseo daemon restart` and `stop --keep-terminals`; `restart
* --kill-terminals`, `stop` and `stop --force` close them. Runs the built CLI and daemon in a
* throwaway home; every process it starts is stopped in `finally`.
*/
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readDaemonInstance } from "@getpaseo/server/daemon-control";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import { getAvailablePort } from "./helpers/network.ts";
if (process.platform === "win32") {
console.log("Skipping terminal survival CLI test on Windows (POSIX shell terminals).");
process.exit(0);
}
console.log("=== Daemon terminals survive restart ===\n");
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-terminals-survive-"));
const workDir = join(paseoHome, "work");
await mkdir(workDir, { recursive: true });
const port = await getAvailablePort();
const env = {
PASEO_HOME: paseoHome,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
// The daemon inherits this; terminals then run a plain POSIX shell with no startup files.
SHELL: "/bin/sh",
};
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor<T>(
probe: () => Promise<T | null | false> | T | null | false,
message: string,
timeoutMs = 30_000,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await probe();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(message);
}
async function paseo(args: string[], expectSuccess = true) {
const result = await runLocalPaseo(args, env);
if (expectSuccess) {
assert.equal(
result.exitCode,
0,
`paseo ${args.join(" ")} failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
return result;
}
async function json<T>(args: string[]): Promise<T> {
return JSON.parse((await paseo([...args, "--json"])).stdout) as T;
}
async function capture(terminalId: string): Promise<string> {
const { lines } = await json<{ lines: string[] }>(["terminal", "capture", terminalId]);
return lines.join("\n");
}
async function waitForScreen(terminalId: string, text: string): Promise<void> {
await waitFor(
async () => (await capture(terminalId)).includes(text),
`terminal ${terminalId} never showed ${text}:\n${await capture(terminalId)}`,
);
}
async function terminalIds(): Promise<string[]> {
return (await json<Array<{ id: string }>>(["terminal", "ls", "--all"])).map((t) => t.id);
}
// Worker records and the cleanup records a kill that could not finish leaves beside them.
function workerRecordCount(): number {
const dir = join(paseoHome, "terminal-worker");
return existsSync(dir)
? readdirSync(dir).filter((name) => /^(\d+|closing-.+)\.json$/.test(name)).length
: 0;
}
// A shell terminal that reports its pid and prints a marker.
async function openTerminal(name: string): Promise<{ id: string; pid: number }> {
const terminal = await json<{ id: string }>([
"terminal",
"create",
"--cwd",
workDir,
"--name",
name,
]);
const pidFile = join(workDir, `${name}.pid`);
await paseo([
"terminal",
"send-keys",
terminal.id,
"-l",
`echo $$ > '${pidFile}'; echo MARKER-${name}`,
]);
await paseo(["terminal", "send-keys", terminal.id, "Enter"]);
const pid = await waitFor(
() =>
existsSync(pidFile) && readFileSync(pidFile, "utf8").trim()
? Number(readFileSync(pidFile, "utf8"))
: null,
`terminal ${name} never wrote its pid`,
);
await waitForScreen(terminal.id, `MARKER-${name}`);
return { id: terminal.id, pid };
}
async function expectKept(terminal: { id: string; pid: number }, name: string, trigger: string) {
assert((await terminalIds()).includes(terminal.id), `${trigger}: terminal is still listed`);
assert(isAlive(terminal.pid), `${trigger}: terminal program is still alive`);
assert((await capture(terminal.id)).includes(`MARKER-${name}`), `${trigger}: marker is kept`);
await paseo(["terminal", "send-keys", terminal.id, "-l", `echo after-${trigger}`]);
await paseo(["terminal", "send-keys", terminal.id, "Enter"]);
await waitForScreen(terminal.id, `\nafter-${trigger}`);
}
async function expectClosed(pid: number, trigger: string) {
await waitFor(() => !isAlive(pid), `${trigger}: terminal program ${pid} is still alive`, 15_000);
}
// A zombie: a real process with a birth time that SIGKILL cannot remove while its parent (an
// exec'd `sleep` that never waits) lives. Cleanup can only report it as still alive.
async function startZombie(): Promise<{ zombiePid: number; parentPid: number }> {
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
});
const zombiePid = await new Promise<number>((resolve) => {
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
});
parent.unref();
await waitFor(
async () => (await psField(zombiePid, "stat")).startsWith("Z"),
`process ${zombiePid} never became a zombie`,
);
return { zombiePid, parentPid: parent.pid! };
}
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
const child = spawn("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
stdio: ["ignore", "pipe", "ignore"],
});
let out = "";
child.stdout.on("data", (chunk: Buffer) => (out += chunk.toString()));
await new Promise((resolve) => child.once("close", resolve));
return out.trim();
}
async function goneProcessPid(): Promise<number> {
const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise((resolve) => child.once("exit", resolve));
return child.pid!;
}
// A record as a dead terminal worker leaves it, naming one terminal program that is a zombie.
async function plantRecordNaming(programPid: number): Promise<void> {
const workerPid = await goneProcessPid();
const record = {
recordId: "planted-record",
pid: workerPid,
bootId: null,
createdAt: new Date().toISOString(),
identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" },
protocolVersion: 1,
paseoVersion: "0.0.0-test",
socketPath: join(paseoHome, "planted-worker.sock"),
secret: "planted-secret",
terminals: [
{ id: "planted-terminal", pid: programPid, startedAt: await psField(programPid, "lstart") },
],
};
writeFileSync(
join(paseoHome, "terminal-worker", `${workerPid}.json`),
`${JSON.stringify(record, null, 2)}\n`,
{ mode: 0o600 },
);
}
const trackedPids: number[] = [];
try {
for (const [field, value] of [
["daemon.listen", `127.0.0.1:${port}`],
["daemon.relay.enabled", "false"],
["features.dictation.enabled", "false"],
["features.voiceMode.enabled", "false"],
] as const) {
await paseo(["daemon", "config", "set", field, value]);
}
await paseo(["daemon", "start"]);
console.log("Test 1: daemon restart keeps the terminal");
const kept = await openTerminal("kept");
trackedPids.push(kept.pid);
await paseo(["daemon", "restart", "--home", paseoHome]);
await expectKept(kept, "kept", "restart");
console.log("✓ same id, same program, marker and input after restart\n");
console.log("Test 2: stop --keep-terminals, then start, gives the terminal back");
const forceAndKeep = await paseo(
["daemon", "stop", "--home", paseoHome, "--force", "--keep-terminals"],
false,
);
assert.notEqual(forceAndKeep.exitCode, 0, "--force with --keep-terminals is refused");
await paseo(["daemon", "stop", "--home", paseoHome, "--keep-terminals"]);
assert.equal(await readDaemonInstance(paseoHome), null, "the supervisor stopped");
assert(isAlive(kept.pid), "the program outlives the stopped daemon");
await paseo(["daemon", "start"]);
await expectKept(kept, "kept", "keep-stop");
console.log("✓ adopted with the same id and still usable\n");
console.log("Test 3: a plain stop with no daemon running closes kept terminals");
await paseo(["daemon", "stop", "--home", paseoHome, "--keep-terminals"]);
assert(isAlive(kept.pid), "kept again");
await paseo(["daemon", "stop", "--home", paseoHome]);
await expectClosed(kept.pid, "stop without a supervisor");
assert.equal(workerRecordCount(), 0, "no worker record is left");
console.log("✓ closed\n");
console.log("Test 4: restart --kill-terminals closes the terminal");
await paseo(["daemon", "start"]);
const killedByRestart = await openTerminal("restart-kill");
trackedPids.push(killedByRestart.pid);
await paseo(["daemon", "restart", "--home", paseoHome, "--kill-terminals"]);
await expectClosed(killedByRestart.pid, "restart --kill-terminals");
assert.deepEqual(await terminalIds(), [], "no terminal is listed after the kill restart");
console.log("✓ closed\n");
console.log("Test 5: daemon stop closes the terminal and its worker");
const killedByStop = await openTerminal("stop-kill");
trackedPids.push(killedByStop.pid);
await paseo(["daemon", "stop", "--home", paseoHome]);
await expectClosed(killedByStop.pid, "stop");
assert.equal(workerRecordCount(), 0, "no worker record is left");
console.log("✓ closed\n");
console.log("Test 6: daemon stop --force closes the terminal");
await paseo(["daemon", "start"]);
const killedByForce = await openTerminal("force-kill");
trackedPids.push(killedByForce.pid);
await paseo(["daemon", "stop", "--home", paseoHome, "--force"]);
await expectClosed(killedByForce.pid, "stop --force");
assert.equal(workerRecordCount(), 0, "no worker record is left");
console.log("✓ closed\n");
const endpoint = `127.0.0.1:${port}`;
console.log("Test 7: restart --host --kill-terminals closes the terminal");
await paseo(["daemon", "start"]);
const killedByEndpointRestart = await openTerminal("endpoint-restart-kill");
trackedPids.push(killedByEndpointRestart.pid);
await paseo(["daemon", "restart", "--host", endpoint, "--kill-terminals"]);
await expectClosed(killedByEndpointRestart.pid, "restart --host --kill-terminals");
assert.deepEqual(await terminalIds(), [], "no terminal is listed after the kill restart");
console.log("✓ closed and confirmed by the replacement\n");
console.log("Test 8: restart --host --kill-terminals reports a process cleanup could not end");
const zombie = await startZombie();
trackedPids.push(zombie.parentPid);
await plantRecordNaming(zombie.zombiePid);
const unconfirmed = await paseo(
["daemon", "restart", "--host", endpoint, "--kill-terminals"],
false,
);
assert.notEqual(unconfirmed.exitCode, 0, "the kill restart fails");
const output = unconfirmed.stdout + unconfirmed.stderr;
assert(output.includes("Terminal cleanup was not confirmed"), `names the failure:\n${output}`);
assert(output.includes(`pid ${zombie.zombiePid}`), `names the surviving pid:\n${output}`);
process.kill(zombie.parentPid, "SIGKILL");
await expectClosed(zombie.zombiePid, "zombie reaped once its parent exits");
await paseo(["daemon", "stop", "--home", paseoHome]);
assert.equal(workerRecordCount(), 0, "the next stop retries the cleanup and retires the record");
console.log("✓ reported, then retried by stop\n");
console.log("Test 9: restart --host --kill-terminals reports a worker record it cannot read");
await paseo(["daemon", "start"]);
// A kill cleanup cannot check what an unreadable record names, and leaves nothing else
// behind, so the replacement daemon must still report it.
const unreadablePath = join(paseoHome, "terminal-worker", `${await goneProcessPid()}.json`);
writeFileSync(unreadablePath, "{ not json", { mode: 0o600 });
const unreadable = await paseo(
["daemon", "restart", "--host", endpoint, "--kill-terminals"],
false,
);
assert.notEqual(unreadable.exitCode, 0, "the kill restart fails");
const unreadableOutput = unreadable.stdout + unreadable.stderr;
assert(unreadableOutput.includes(unreadablePath), `names the record:\n${unreadableOutput}`);
assert(existsSync(unreadablePath), "the unreadable record is kept");
rmSync(unreadablePath);
await paseo(["daemon", "stop", "--home", paseoHome]);
assert.equal(workerRecordCount(), 0, "no worker record is left");
console.log("✓ reported with the record's path\n");
} finally {
await runLocalPaseo(["daemon", "stop", "--home", paseoHome, "--force"], env);
for (const pid of trackedPids) {
if (isAlive(pid)) process.kill(pid, "SIGKILL");
}
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Daemon terminals survive restart passed ===");
@@ -0,0 +1,527 @@
#!/usr/bin/env npx tsx
/**
* A terminal worker survives an upgrade of the installation that started it. Two disposable
* installations A and B (built dist, real copies of @getpaseo/server, @getpaseo/cli and
* node-pty, every other dependency a symlink into this repo) each get their own CLI shim, so
* each daemon's PASEO_CLI is a distinct path:
*
* 1. A daemon from A runs its worker from a copy in $PASEO_HOME/terminal-worker/. After
* `stop --keep-terminals` and moving A's node-pty, worker bundle and shell integration away,
* a daemon from B adopts the same worker and opens a new, working terminal in it with B's
* environment and CLI, while the first terminal keeps A's.
* 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): a worker one version
* old drains (keeps its terminal, new ones open in a second worker, it exits after its last
* terminal and its copy is deleted); one two versions old is shut down; one from a newer
* Paseo is left running, reported as terminal_worker_incompatible, and closed by
* `daemon stop`.
*
* Every process it starts is stopped in `finally`.
*/
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getAvailablePort } from "./helpers/network.ts";
if (process.platform === "win32") {
console.log("Skipping terminal worker upgrade CLI test on Windows (POSIX shell terminals).");
process.exit(0);
}
console.log("=== Terminal worker survives an upgrade ===\n");
const repoRoot = join(import.meta.dirname, "..", "..", "..");
const root = await mkdtemp(join(tmpdir(), "paseo-worker-upgrade-"));
const paseoHome = join(root, "home");
const cliHome = join(root, "os-home");
const workDir = join(root, "work");
for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true });
const port = await getAvailablePort();
const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
interface Installation {
name: string;
dir: string;
version: string;
shim: string;
serverDir: string;
}
// ---------------------------------------------------------------------------------------------
// Installation fixtures
// ---------------------------------------------------------------------------------------------
function linkEntries(source: string, target: string, taken: Set<string>): void {
if (!existsSync(source)) return;
for (const name of readdirSync(source)) {
if (name.startsWith(".")) continue;
if (name.startsWith("@")) {
mkdirSync(join(target, name), { recursive: true });
for (const scoped of readdirSync(join(source, name))) {
const key = `${name}/${scoped}`;
if (taken.has(key)) continue;
taken.add(key);
symlinkSync(join(source, name, scoped), join(target, name, scoped));
}
continue;
}
if (taken.has(name)) continue;
taken.add(name);
symlinkSync(join(source, name), join(target, name));
}
}
function createInstallation(name: string, version: string): Installation {
const dir = join(root, `install-${name}`);
const modules = join(dir, "node_modules");
mkdirSync(join(modules, "@getpaseo"), { recursive: true });
// Real copies: the server (its version marks the installation), the CLI (so it resolves this
// installation's server), and node-pty (so moving it away breaks only this installation).
const serverDir = join(modules, "@getpaseo", "server");
const serverPackage = JSON.parse(
readFileSync(join(repoRoot, "packages/server/package.json"), "utf8"),
) as Record<string, unknown>;
mkdirSync(join(serverDir, "dist"), { recursive: true });
writeFileSync(
join(serverDir, "package.json"),
`${JSON.stringify({ ...serverPackage, version }, null, 2)}\n`,
);
for (const part of ["server", "src", "scripts"]) {
cpSync(join(repoRoot, "packages/server/dist", part), join(serverDir, "dist", part), {
recursive: true,
});
}
const cliDir = join(modules, "@getpaseo", "cli");
mkdirSync(cliDir, { recursive: true });
cpSync(join(repoRoot, "packages/cli/package.json"), join(cliDir, "package.json"));
cpSync(join(repoRoot, "packages/cli/dist"), join(cliDir, "dist"), { recursive: true });
cpSync(join(repoRoot, "packages/server/node_modules/node-pty"), join(modules, "node-pty"), {
recursive: true,
});
const taken = new Set(["@getpaseo/server", "@getpaseo/cli", "node-pty"]);
for (const source of [
"packages/server/node_modules",
"packages/cli/node_modules",
"node_modules",
]) {
linkEntries(join(repoRoot, source), modules, taken);
}
const shim = join(dir, "bin", "paseo");
mkdirSync(join(dir, "bin"));
writeFileSync(
shim,
`#!/bin/sh\nexec "${process.execPath}" "${join(cliDir, "dist", "index.js")}" "$@"\n`,
);
chmodSync(shim, 0o755);
return { name, dir, version, shim, serverDir };
}
// The runtime files an upgrade replaces: node-pty, the worker bundle, the shell integration.
function moveRuntimeAway(installation: Installation): () => void {
const away = join(installation.dir, "moved-away");
const moves = [
join(installation.dir, "node_modules", "node-pty"),
join(installation.serverDir, "dist", "server", "terminal-worker"),
join(installation.serverDir, "dist", "server", "terminal", "shell-integration"),
].map((from, index) => ({ from, to: join(away, String(index)) }));
mkdirSync(away, { recursive: true });
for (const move of moves) renameSync(move.from, move.to);
return () => {
for (const move of moves) renameSync(move.to, move.from);
};
}
// ---------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor<T>(
probe: () => Promise<T | null | false> | T | null | false,
message: () => string | Promise<string>,
timeoutMs = 30_000,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await probe();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(await message());
}
interface RunResult {
exitCode: number;
stdout: string;
stderr: string;
}
function run(
installation: Installation,
args: string[],
extraEnv: Record<string, string> = {},
): Promise<RunResult> {
const env = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
HOME: cliHome,
USERPROFILE: cliHome,
PASEO_HOME: paseoHome,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
// Each daemon hands its terminals its own installation's CLI.
PASEO_CLI: installation.shim,
PASEO_TEST_INSTALL: installation.name,
SHELL: "/bin/sh",
...extraEnv,
};
return new Promise((resolve) => {
execFile(installation.shim, args, { env, timeout: 120_000 }, (error, stdout, stderr) => {
let exitCode = 0;
if (error) exitCode = typeof error.code === "number" ? error.code : 1;
resolve({ exitCode, stdout, stderr });
});
});
}
async function paseo(
installation: Installation,
args: string[],
extraEnv: Record<string, string> = {},
): Promise<RunResult> {
const result = await run(installation, args, extraEnv);
assert.equal(
result.exitCode,
0,
`paseo ${args.join(" ")} (${installation.name}) failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
return result;
}
async function json<T>(installation: Installation, args: string[]): Promise<T> {
return JSON.parse((await paseo(installation, [...args, "--json"])).stdout) as T;
}
async function capture(installation: Installation, terminalId: string): Promise<string> {
const { lines } = await json<{ lines: string[] }>(installation, [
"terminal",
"capture",
terminalId,
]);
return lines.join("\n");
}
async function type(installation: Installation, terminalId: string, line: string) {
await paseo(installation, ["terminal", "send-keys", terminalId, "-l", line]);
await paseo(installation, ["terminal", "send-keys", terminalId, "Enter"]);
}
async function waitForScreen(installation: Installation, terminalId: string, text: string) {
await waitFor(
async () => (await capture(installation, terminalId)).includes(text),
async () =>
`terminal ${terminalId} never showed ${text}:\n${await capture(installation, terminalId)}`,
);
}
// Opens a terminal, records its shell's pid, and prints a marker.
async function openTerminal(
installation: Installation,
name: string,
): Promise<{ id: string; pid: number }> {
const terminal = await json<{ id: string }>(installation, [
"terminal",
"create",
"--cwd",
workDir,
"--name",
name,
]);
const pidFile = join(workDir, `${name}.pid`);
await type(installation, terminal.id, `echo $$ > '${pidFile}'; echo MARKER-${name}`);
const pid = await waitFor(
() =>
existsSync(pidFile) && readFileSync(pidFile, "utf8").trim()
? Number(readFileSync(pidFile, "utf8"))
: null,
() => `terminal ${name} never wrote its pid`,
);
await waitForScreen(installation, terminal.id, `MARKER-${name}`);
trackedPids.push(pid);
return { id: terminal.id, pid };
}
// The value of `$variable` in the terminal's shell, written to a file (a long path would wrap
// on screen).
async function echoVariable(
installation: Installation,
terminalId: string,
label: string,
variable: string,
): Promise<string> {
const valueFile = join(workDir, `${label}.value`);
await type(
installation,
terminalId,
`printf '%s' "$${variable}" > '${valueFile}.tmp' && mv '${valueFile}.tmp' '${valueFile}'`,
);
await waitFor(
() => existsSync(valueFile),
async () =>
`terminal ${terminalId} never wrote ${label}:\n${await capture(installation, terminalId)}`,
);
return readFileSync(valueFile, "utf8");
}
interface WorkerRecordFile {
pid: number;
protocolVersion: unknown;
runtime?: string | null;
terminals: Array<{ id: string; pid: number }>;
}
function workerRecords(): WorkerRecordFile[] {
const dir = join(paseoHome, "terminal-worker");
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((name) => /^\d+\.json$/.test(name))
.map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")) as WorkerRecordFile);
}
function runtimeCopies(): string[] {
const dir = join(paseoHome, "terminal-worker");
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((name) =>
existsSync(join(dir, name, "paseo-terminal-worker-runtime.json")),
);
}
function commandLineOf(pid: number): Promise<string> {
return new Promise((resolve) => {
execFile("ps", ["-ww", "-o", "command=", "-p", String(pid)], (_error, stdout) =>
resolve(stdout.trim()),
);
});
}
async function terminalIds(installation: Installation): Promise<string[]> {
return (await json<Array<{ id: string }>>(installation, ["terminal", "ls", "--all"])).map(
(terminal) => terminal.id,
);
}
async function startDaemon(installation: Installation, extraEnv: Record<string, string> = {}) {
await paseo(installation, ["daemon", "start"], extraEnv);
}
async function stopDaemon(installation: Installation, keepTerminals = false) {
await paseo(installation, [
"daemon",
"stop",
"--home",
paseoHome,
...(keepTerminals ? ["--keep-terminals"] : []),
]);
}
// A terminal in a worker reporting `protocolVersion`, left running by `stop --keep-terminals`.
async function leaveWorkerWithProtocol(
installation: Installation,
protocolVersion: number,
name: string,
): Promise<{ terminal: { id: string; pid: number }; workerPid: number; runtime: string }> {
await startDaemon(installation, {
PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE: String(protocolVersion),
});
const terminal = await openTerminal(installation, name);
const [worker] = workerRecords();
assert(worker, "the daemon started a worker");
assert.equal(worker.protocolVersion, protocolVersion, "the worker reports the override");
await stopDaemon(installation, true);
return { terminal, workerPid: worker.pid, runtime: worker.runtime! };
}
const trackedPids: number[] = [];
const a = createInstallation("A", "0.98.0");
const b = createInstallation("B", "0.99.0");
let restoreA: (() => void) | null = null;
let passed = false;
try {
for (const [field, value] of [
["daemon.listen", `127.0.0.1:${port}`],
["daemon.relay.enabled", "false"],
["features.dictation.enabled", "false"],
["features.voiceMode.enabled", "false"],
] as const) {
await paseo(a, ["daemon", "config", "set", field, value]);
}
console.log("Test 1: a daemon from A runs its worker from a copy in the home");
await startDaemon(a);
const t1 = await openTerminal(a, "t1");
const [firstWorker] = workerRecords();
assert(firstWorker, "one worker runs");
assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/);
const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!);
assert(
(await commandLineOf(firstWorker.pid)).includes(join(copyA, "terminal-worker.mjs")),
"the worker runs the copy's bundle",
);
assert.equal(await echoVariable(a, t1.id, "CLI1", "PASEO_CLI"), a.shim, "T1 has A's CLI");
console.log("✓ worker runs from", copyA, "\n");
console.log("Test 2: B adopts the worker after A's runtime files are gone");
await stopDaemon(a, true);
restoreA = moveRuntimeAway(a);
await startDaemon(b, zsh ? { SHELL: zsh } : {});
assert.deepEqual(
workerRecords().map((record) => record.pid),
[firstWorker.pid],
"B adopted the same worker instead of starting one",
);
assert((await terminalIds(b)).includes(t1.id), "T1 is listed by B");
const t2 = await openTerminal(b, "t2");
assert.equal(await echoVariable(b, t2.id, "INSTALL2", "PASEO_TEST_INSTALL"), "B");
assert.equal(await echoVariable(b, t2.id, "CLI2", "PASEO_CLI"), b.shim, "T2 has B's CLI");
if (zsh) {
// Creating a zsh terminal reads Paseo's integration from the worker's copy (A's is gone);
// a missing file would have failed the create.
const zshVersion = await echoVariable(b, t2.id, "ZSH", "ZSH_VERSION");
assert(zshVersion.length > 0, "T2 runs zsh with Paseo's shell integration");
} else {
console.log(" (zsh is not installed: the shell-integration check is skipped)");
}
assert.equal(await echoVariable(b, t1.id, "CLI1AGAIN", "PASEO_CLI"), a.shim, "T1 keeps A's CLI");
console.log("✓ same worker, T2 uses B's environment, T1 still works\n");
console.log("Test 3: daemon stop closes both terminals and deletes the copy");
await stopDaemon(b);
await waitFor(
() => !isAlive(t1.pid) && !isAlive(t2.pid) && !isAlive(firstWorker.pid),
() => "terminals or worker outlived daemon stop",
);
assert.deepEqual(workerRecords(), [], "no worker record is left");
assert.deepEqual(runtimeCopies(), [], "no copy is left");
restoreA();
restoreA = null;
console.log("✓ closed and cleaned up\n");
console.log("Test 4: a worker one protocol version old drains");
const old = await leaveWorkerWithProtocol(a, 0, "t3");
await startDaemon(b);
const workers = workerRecords();
assert.equal(workers.length, 2, "the old worker drains beside a new one");
const current = workers.find((record) => record.pid !== old.workerPid)!;
assert.equal(current.protocolVersion, 1);
assert.match(current.runtime ?? "", /^0\.99\.0-/);
assert((await terminalIds(b)).includes(old.terminal.id), "the old worker's terminal is listed");
await type(b, old.terminal.id, "echo still-served");
await waitForScreen(b, old.terminal.id, "\nstill-served");
const t4 = await openTerminal(b, "t4");
assert.deepEqual(
workerRecords()
.find((record) => record.pid === current.pid)!
.terminals.map((terminal) => terminal.id),
[t4.id],
"new terminals open in the current worker",
);
await paseo(b, ["terminal", "kill", old.terminal.id]);
await waitFor(
() => !isAlive(old.workerPid),
() => `the drained worker ${old.workerPid} did not exit`,
);
await waitFor(
() => !runtimeCopies().includes(old.runtime),
() => `the drained worker's copy ${old.runtime} was not deleted: ${runtimeCopies().join(", ")}`,
);
assert.deepEqual(runtimeCopies(), [current.runtime], "the current worker's copy stays");
await type(b, t4.id, "echo after-drain");
await waitForScreen(b, t4.id, "\nafter-drain");
await stopDaemon(b);
console.log("✓ drained, exited, copy deleted\n");
console.log("Test 5: a worker two protocol versions old is shut down");
const obsolete = await leaveWorkerWithProtocol(a, -1, "t5");
await startDaemon(b);
await waitFor(
() => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid),
() => "the obsolete worker or its terminal survived",
);
assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone");
assert.equal(workerRecords().length, 1, "one current worker runs");
await stopDaemon(b);
console.log("✓ shut down\n");
console.log("Test 6: a worker from a newer Paseo is left running and reported");
const newer = await leaveWorkerWithProtocol(a, 2, "t6");
await startDaemon(b);
const listed = await run(b, ["terminal", "ls", "--all", "--json"]);
assert.notEqual(listed.exitCode, 0, "terminal ls fails");
assert.match(listed.stdout + listed.stderr, /held by a newer Paseo \(v0\.98\.0/);
const created = await run(b, ["terminal", "create", "--cwd", workDir, "--json"]);
assert.notEqual(created.exitCode, 0, "terminal create fails");
assert.match(created.stdout + created.stderr, /held by a newer Paseo/);
const status = await paseo(b, ["daemon", "status", "--home", paseoHome]);
assert.match(status.stdout, /terminals: Terminals are held by a newer Paseo/);
assert(isAlive(newer.terminal.pid), "its program keeps running");
assert.deepEqual(
workerRecords().map((record) => record.pid),
[newer.workerPid],
"no second worker starts",
);
await stopDaemon(b);
await waitFor(
() => !isAlive(newer.workerPid) && !isAlive(newer.terminal.pid),
() => "daemon stop left the newer worker or its terminal running",
);
assert.deepEqual(workerRecords(), [], "no worker record is left");
assert.deepEqual(runtimeCopies(), [], "no copy is left");
console.log("✓ reported, untouched, closed by stop\n");
passed = true;
} finally {
if (!passed) {
for (const log of ["daemon.log", "terminal-worker.log"]) {
const logPath = join(paseoHome, log);
if (existsSync(logPath)) {
console.error(`--- ${log} (last 60 lines) ---`);
console.error(readFileSync(logPath, "utf8").split("\n").slice(-60).join("\n"));
}
}
}
restoreA?.();
for (const installation of [b, a]) {
await run(installation, ["daemon", "stop", "--home", paseoHome, "--force"]);
}
for (const pid of trackedPids) {
if (isAlive(pid)) process.kill(pid, "SIGKILL");
}
await rm(root, { recursive: true, force: true });
}
console.log("=== Terminal worker survives an upgrade passed ===");
+22 -24
View File
@@ -18,7 +18,7 @@ import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { ChildProcess, spawn } from "child_process"; import { ChildProcess, spawn } from "child_process";
import { getAvailablePort } from "./network.ts"; import { readDaemonInstance } from "@getpaseo/server/daemon-control";
export interface TestDaemonContext { export interface TestDaemonContext {
/** Available local port for test daemon (never 6767) */ /** Available local port for test daemon (never 6767) */
@@ -195,17 +195,20 @@ async function probeDaemonReady(
} }
async function waitForDaemonReady( async function waitForDaemonReady(
port: number,
paseoHome: string, paseoHome: string,
timeout = 30000, timeout = 30000,
env?: NodeJS.ProcessEnv, env?: NodeJS.ProcessEnv,
): Promise<void> { ): Promise<number> {
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
async function poll(): Promise<void> { async function poll(): Promise<number> {
if (await probeDaemonReady(port, paseoHome, env)) return; const instance = await readDaemonInstance(paseoHome);
if (instance?.listen) {
const port = Number(new URL(`http://${instance.listen}`).port);
if (await probeDaemonReady(port, paseoHome, env)) return port;
}
if (Date.now() >= deadline) { if (Date.now() >= deadline) {
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); throw new Error(`Daemon failed to publish a ready endpoint within ${timeout}ms`);
} }
await sleep(100); await sleep(100);
return poll(); return poll();
@@ -231,15 +234,14 @@ export async function startTestDaemon(options?: {
timeout?: number; timeout?: number;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
}): Promise<TestDaemonContext> { }): Promise<TestDaemonContext> {
const port = options?.port ?? (await getAvailablePort()); // Let the listening process allocate its port, without a reserve/release race.
const requestedPort = options?.port ?? 0;
const { paseoHome, workDir } = const { paseoHome, workDir } =
options?.paseoHome && options?.workDir options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir } ? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs(); : await createTempDirs();
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`;
// Find the CLI entry point - use the source file directly with tsx // Find the CLI entry point - use the source file directly with tsx
const cliDir = join(import.meta.dirname, "..", ".."); const cliDir = join(import.meta.dirname, "..", "..");
const cliSrcPath = join(cliDir, "src", "index.ts"); const cliSrcPath = join(cliDir, "src", "index.ts");
@@ -252,7 +254,7 @@ export async function startTestDaemon(options?: {
), ),
...TEST_DAEMON_ENV_DEFAULTS, ...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome, PASEO_HOME: paseoHome,
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, PASEO_LISTEN: `${TEST_DAEMON_HOST}:${requestedPort}`,
// Force no TTY to prevent QR code output // Force no TTY to prevent QR code output
CI: "true", CI: "true",
...options?.env, ...options?.env,
@@ -312,20 +314,18 @@ export async function startTestDaemon(options?: {
} }
}); });
const ctx: TestDaemonContext = {
port,
wsUrl,
paseoHome,
workDir,
process: daemonProcess,
isReady: false,
stop: cleanup,
};
// Wait for daemon to be ready // Wait for daemon to be ready
try { try {
await waitForDaemonReady(port, paseoHome, timeout, options?.env); const port = await waitForDaemonReady(paseoHome, timeout, options?.env);
ctx.isReady = true; return {
port,
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
paseoHome,
workDir,
process: daemonProcess,
isReady: true,
stop: cleanup,
};
} catch (err) { } catch (err) {
// Daemon failed to start - clean up and rethrow // Daemon failed to start - clean up and rethrow
await cleanup(); await cleanup();
@@ -335,8 +335,6 @@ export async function startTestDaemon(options?: {
{ cause: err }, { cause: err },
); );
} }
return ctx;
} }
/** /**
+185 -1
View File
@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { import {
DaemonClient, DaemonClient,
@@ -9,6 +9,8 @@ import {
type CreateAgentRequestOptions, type CreateAgentRequestOptions,
type DaemonTransport, type DaemonTransport,
type Logger, type Logger,
type PluginClientBundle,
type PluginClientBundleStore,
} from "./daemon-client"; } from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
@@ -2099,6 +2101,188 @@ test("gets a structured plugin log snapshot", async () => {
]); ]);
}); });
describe("plugin catalog", () => {
async function connectedClient(createStore?: (serverId: string) => PluginClientBundleStore) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
createPluginClientBundleStore: createStore,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { plugins: true } });
await connectPromise;
return { client, mock };
}
async function fetchCatalog(
harness: Awaited<ReturnType<typeof connectedClient>>,
respond: (request: Record<string, unknown>) => Array<Record<string, unknown>>,
) {
const before = harness.mock.sent.length;
const response = harness.client.getPluginCatalog();
await vi.waitFor(() => expect(harness.mock.sent.length).toBeGreaterThan(before));
const request = parseSentFrame(harness.mock.sent.at(-1));
harness.mock.triggerMessage(
wrapSessionMessage({
type: "plugin.catalog.get.response",
payload: { requestId: request.requestId, plugins: respond(request) },
}),
);
return { request, plugins: await response };
}
test("reuses retained client bundles the daemon leaves out", async () => {
const harness = await connectedClient();
const first = await fetchCatalog(harness, () => [
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "old bundle", clientBundleHash: "hash-old" },
]);
expect(first.request).not.toHaveProperty("cachedClientBundles");
const second = await fetchCatalog(harness, () => [
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
]);
expect(second.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundleHash: "hash-old" },
]);
expect(second.plugins).toEqual([
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
]);
const third = await fetchCatalog(harness, () => []);
expect(third.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundleHash: "hash-new" },
]);
const fourth = await fetchCatalog(harness, () => []);
expect(fourth.request).not.toHaveProperty("cachedClientBundles");
});
test("keeps fetching every bundle from a daemon that sends no bundle hashes", async () => {
const harness = await connectedClient();
const legacyCatalog = () => [{ id: "legacy", clientBundle: "legacy bundle" }];
await fetchCatalog(harness, legacyCatalog);
const second = await fetchCatalog(harness, legacyCatalog);
expect(second.request).not.toHaveProperty("cachedClientBundles");
expect(second.plugins).toEqual([{ id: "legacy", clientBundle: "legacy bundle" }]);
});
test("a fresh client advertises persisted bundles on its first fetch and expands omitted code", async () => {
let persisted: PluginClientBundle[] = [];
let loads = 0;
const store: PluginClientBundleStore = {
async load() {
loads++;
return persisted;
},
async replace(bundles) {
persisted = bundles;
},
};
const createdFor: string[] = [];
const createStore = (serverId: string) => {
createdFor.push(serverId);
return store;
};
const bundle = { id: "kept", clientBundleHash: "hash-kept", clientBundle: "stored code" };
const warm = await connectedClient(createStore);
await fetchCatalog(warm, () => [bundle]);
const cold = await connectedClient(createStore);
const first = await fetchCatalog(cold, () => [{ id: "kept", clientBundleHash: "hash-kept" }]);
expect(first.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
]);
expect(first.plugins).toEqual([bundle]);
expect(loads).toBe(2);
await fetchCatalog(cold, () => []);
expect(loads).toBe(2);
expect(createdFor).toEqual([
warm.client.getLastServerInfoMessage()!.serverId,
cold.client.getLastServerInfoMessage()!.serverId,
]);
expect(persisted).toEqual([]);
});
test.each(["missing", "unreadable"])(
"a %s persistent cache requests full bundles",
async (state) => {
const store: PluginClientBundleStore = {
async load() {
if (state === "unreadable") throw new Error("Storage unavailable");
return [];
},
async replace() {},
};
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
const result = await fetchCatalog(await connectedClient(() => store), () => [bundle]);
expect(result.request).not.toHaveProperty("cachedClientBundles");
expect(result.plugins).toEqual([bundle]);
},
);
test("failed cache writes never fail plugin loading", async () => {
const store: PluginClientBundleStore = {
async load() {
return [];
},
async replace() {
throw new Error("Disk full");
},
};
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
expect(
(await fetchCatalog(await connectedClient(() => store), () => [bundle])).plugins,
).toEqual([bundle]);
});
test.each([false, true])(
"usable catalogs do not wait for storage; latest write survives a failed first write: %s",
async (failFirstWrite) => {
const writes: PluginClientBundle[][] = [];
let finishWrite = () => {};
const blockedWrite = new Promise<void>((resolve) => {
finishWrite = resolve;
});
const store: PluginClientBundleStore = {
async load() {
return [];
},
async replace(bundles) {
writes.push(bundles);
if (writes.length === 1) {
await blockedWrite;
if (failFirstWrite) throw new Error("Disk full");
}
},
};
const harness = await connectedClient(() => store);
const first = { id: "plugin", clientBundle: "old", clientBundleHash: "hash-old" };
const changed = { id: "plugin", clientBundle: "new", clientBundleHash: "hash-new" };
try {
expect((await fetchCatalog(harness, () => [first])).plugins).toEqual([first]);
await vi.waitFor(() => expect(writes).toEqual([[first]]));
expect((await fetchCatalog(harness, () => [changed])).plugins).toEqual([changed]);
expect((await fetchCatalog(harness, () => [])).plugins).toEqual([]);
expect(writes).toEqual([[first]]);
finishWrite();
await vi.waitFor(() => expect(writes).toEqual([[first], []]));
} finally {
finishWrite();
}
},
);
});
test("keeps a plugin reload pending past the session RPC deadline", async () => { test("keeps a plugin reload pending past the session RPC deadline", async () => {
useHeartbeatClock(); useHeartbeatClock();
try { try {
+135 -4
View File
@@ -396,6 +396,17 @@ export type DaemonEventHandler = (event: DaemonEvent) => void;
export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest; export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest;
export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse;
export interface PluginClientBundle {
id: string;
clientBundleHash: string;
clientBundle: string;
}
/** Host-scoped storage. load must return only bundles whose contents have been verified. */
export interface PluginClientBundleStore {
load(): Promise<PluginClientBundle[]>;
replace(bundles: PluginClientBundle[]): Promise<void>;
}
/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */ /** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */
export type RelaySecurityMode = "legacy" | "protected"; export type RelaySecurityMode = "legacy" | "protected";
@@ -406,6 +417,8 @@ export interface DaemonClientConfig {
sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[]; sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[];
url: string; url: string;
clientId: string; clientId: string;
/** Create one cache for the confirmed daemon identity, retained for this client lifetime. */
createPluginClientBundleStore?: (serverId: string) => PluginClientBundleStore;
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
appVersion?: string; appVersion?: string;
runtimeGeneration?: number | null; runtimeGeneration?: number | null;
@@ -768,6 +781,9 @@ type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayl
export interface ShutdownServerOptions { export interface ShutdownServerOptions {
requestId?: string; requestId?: string;
timeout?: number; timeout?: number;
// Leave terminals running for the next daemon. Only daemons advertising
// `features.terminalsSurviveRestart` honor it; check before sending.
keepTerminals?: boolean;
} }
export interface DaemonStatusOptions { export interface DaemonStatusOptions {
requestId?: string; requestId?: string;
@@ -1016,6 +1032,12 @@ type CorrelatedResponsePayloads = {
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> = type CorrelatedResponsePayload<TType extends CorrelatedResponseType> =
CorrelatedResponsePayloads[TType]; CorrelatedResponsePayloads[TType];
type PluginCatalogResponseEntry =
CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number];
export type PluginCatalogEntry = Omit<PluginCatalogResponseEntry, "clientBundle"> & {
clientBundle: string;
};
export class DaemonConnectionError extends Error { export class DaemonConnectionError extends Error {
readonly serverId?: string; readonly serverId?: string;
readonly outcome?: "unknown"; readonly outcome?: "unknown";
@@ -1267,6 +1289,36 @@ export function supportsUsageReports(features: ServerInfoStatusPayload["features
return features?.usageSources === true || features?.providerUsageList === true; return features?.usageSources === true || features?.providerUsageList === true;
} }
export class TerminalKeepUnsupportedError extends Error {
readonly code = "TERMINALS_KEEP_UNSUPPORTED";
// Own fields reach the CLI's JSON error output, which desktop reads.
readonly version: string | null;
constructor(target: string, version: string | null) {
super(
`The daemon at ${target}${version ? ` (v${version})` : ""} closes terminals when it stops. Update it first, or run \`paseo daemon stop\` to stop it and close them.`,
);
this.name = "TerminalKeepUnsupportedError";
this.version = version;
}
}
/**
* Gate for an explicit "keep terminals" stop. Older daemons drop `keepTerminals` and close the
* terminals anyway, so read the capability on the connection that will carry the shutdown and
* refuse before sending it.
*/
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
export function requireTerminalKeepCapability(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
target: string,
): void {
const serverInfo = client.getLastServerInfoMessage();
if (serverInfo?.features?.terminalsSurviveRestart !== true) {
throw new TerminalKeepUnsupportedError(target, serverInfo?.version ?? null);
}
}
function buildFetchAgentsRequest( function buildFetchAgentsRequest(
options: FetchAgentsOptions | undefined, options: FetchAgentsOptions | undefined,
requestId: string, requestId: string,
@@ -1343,6 +1395,15 @@ export class DaemonClient {
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>(); private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>(); private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
private completedBinaryFileReads = new Map<string, FileReadResult>(); private completedBinaryFileReads = new Map<string, FileReadResult>();
/** The last catalog's client bundles by plugin id, so later catalogs can leave them out. */
private pluginClientBundles = new Map<
string,
{ clientBundleHash: string; clientBundle: string }
>();
private pluginClientBundleStore: PluginClientBundleStore | undefined;
private pluginClientBundlesLoaded: Promise<void> | undefined;
private pluginClientBundlesWrite: PluginClientBundle[] | undefined;
private pluginClientBundlesWriting = false;
private logger: Logger; private logger: Logger;
private pendingSendQueue: PendingSend[] = []; private pendingSendQueue: PendingSend[] = [];
/** /**
@@ -4043,13 +4104,14 @@ export class DaemonClient {
async restartServer( async restartServer(
reason?: string, reason?: string,
requestId?: string, requestId?: string,
options?: { timeout?: number }, options?: { timeout?: number; killTerminals?: boolean },
): Promise<RestartRequestedStatusPayload> { ): Promise<RestartRequestedStatusPayload> {
const resolvedRequestId = this.createRequestId(requestId); const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "restart_server_request", type: "restart_server_request",
...(reason && reason.trim().length > 0 ? { reason } : {}), ...(reason && reason.trim().length > 0 ? { reason } : {}),
requestId: resolvedRequestId, requestId: resolvedRequestId,
...(options?.killTerminals ? { killTerminals: true } : {}),
}); });
return this.sendRequest({ return this.sendRequest({
requestId: resolvedRequestId, requestId: resolvedRequestId,
@@ -4077,6 +4139,7 @@ export class DaemonClient {
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "shutdown_server_request", type: "shutdown_server_request",
requestId: resolvedRequestId, requestId: resolvedRequestId,
...(options?.keepTerminals ? { keepTerminals: true } : {}),
}); });
return this.sendRequest({ return this.sendRequest({
requestId: resolvedRequestId, requestId: resolvedRequestId,
@@ -5793,14 +5856,82 @@ export class DaemonClient {
}); });
} }
async getPluginCatalog() { async getPluginCatalog(): Promise<PluginCatalogEntry[]> {
if (this.config.createPluginClientBundleStore && !this.pluginClientBundleStore) {
const serverInfo = this.getLastServerInfoMessage();
if (!serverInfo) throw new Error("Plugin catalog requires a connected host");
this.pluginClientBundleStore = this.config.createPluginClientBundleStore(serverInfo.serverId);
}
const store = this.pluginClientBundleStore;
if (store) {
this.pluginClientBundlesLoaded ??= store.load().then(
(bundles) => {
for (const { id, ...bundle } of bundles) this.pluginClientBundles.set(id, bundle);
return undefined;
},
(error) => this.logger.warn({ err: error }, "Failed to load plugin client bundles"),
);
await this.pluginClientBundlesLoaded;
}
const requestId = this.createRequestId(); const requestId = this.createRequestId();
const cached = this.pluginClientBundles;
const cachedClientBundles = [...cached].map(([id, { clientBundleHash }]) => ({
id,
clientBundleHash,
}));
const payload = await this.sendCorrelatedSessionRequest({ const payload = await this.sendCorrelatedSessionRequest({
requestId, requestId,
message: { type: "plugin.catalog.get.request", requestId }, message: {
type: "plugin.catalog.get.request",
requestId,
...(cachedClientBundles.length > 0 ? { cachedClientBundles } : {}),
},
responseType: "plugin.catalog.get.response", responseType: "plugin.catalog.get.response",
}); });
return payload.plugins; const retained = new Map<string, { clientBundleHash: string; clientBundle: string }>();
const plugins = payload.plugins.map(({ clientBundle: sent, ...plugin }): PluginCatalogEntry => {
const previous = cached.get(plugin.id);
const clientBundle =
sent ??
(previous?.clientBundleHash === plugin.clientBundleHash
? previous?.clientBundle
: undefined);
if (clientBundle === undefined) {
throw new Error(`Plugin catalog left out the client bundle for ${plugin.id}`);
}
// COMPAT(pluginCatalogBundleHash): introduced by PR #6732 (unreleased); remove after
// 2027-04-27 once the daemon floor includes bundle hashes. Older bundles are not retained.
if (plugin.clientBundleHash !== undefined) {
retained.set(plugin.id, { clientBundleHash: plugin.clientBundleHash, clientBundle });
}
return Object.assign(plugin, { clientBundle });
});
this.pluginClientBundles = retained;
if (store) {
this.pluginClientBundlesWrite = [...retained].map(([id, bundle]) =>
Object.assign({ id }, bundle),
);
void this.persistPluginClientBundles(store);
}
return plugins;
}
private async persistPluginClientBundles(store: PluginClientBundleStore): Promise<void> {
if (this.pluginClientBundlesWriting) return;
this.pluginClientBundlesWriting = true;
try {
while (this.pluginClientBundlesWrite) {
const bundles = this.pluginClientBundlesWrite;
this.pluginClientBundlesWrite = undefined;
try {
await store.replace(bundles);
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist plugin client bundles");
}
}
} finally {
this.pluginClientBundlesWriting = false;
}
} }
async listPlugins(): Promise<PluginListItem[]> { async listPlugins(): Promise<PluginListItem[]> {
@@ -0,0 +1,158 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import electronPath from "electron";
import { build } from "esbuild";
export async function runAdaptiveThemeRegression(artifactDir) {
const { outputFiles } = await build({
stdin: {
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
contents: `
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
StyleSheet.configure({
themes: {
dark: { foreground: "#fafafa", background: "#181b1a" },
light: { foreground: "#1a1a1e", background: "#ffffff" },
},
settings: { adaptiveThemes: true },
});
const Message = withUnistyles(({ color }) =>
createElement("p", { style: { color } }, "Theme-reactive message"));
createRoot(document.getElementById("root")).render(createElement(Message, {
style: { margin: 0 },
uniProps: theme => ({ color: theme.foreground }),
}));
window.readThemeForTest = () => UnistylesRuntime.themeName;
`,
},
bundle: true,
write: false,
format: "iife",
platform: "browser",
alias: { "react-native": "react-native-web" },
define: { __DEV__: "false" },
resolveExtensions: [".web.js", ".js", ".json"],
});
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-"));
try {
await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text);
await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`);
// A separate process avoids Playwright's focus emulation, which keeps hidden pages visible.
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const args = [
...(process.platform === "linux" ? ["--no-sandbox"] : []),
path.join(directory, "main.cjs"),
artifactDir,
];
const command = process.platform === "linux" ? "xvfb-run" : electronPath;
const commandArgs =
process.platform === "linux"
? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args]
: args;
await promisify(execFile)(command, commandArgs, {
env,
timeout: 30_000,
});
return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8"));
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
}
async function electronRegression() {
const { app, BrowserWindow, nativeTheme } = require("electron");
const assert = require("node:assert/strict");
const files = require("node:fs");
const paths = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
app.setPath("userData", paths.join(__dirname, "profile"));
app.on("window-all-closed", () => {});
// Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last.
const artifactDir = process.argv.at(-1);
let window;
const observations = [];
const colors = {
dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" },
light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" },
};
async function read(expression) {
return window.webContents.executeJavaScript(expression);
}
async function expectValue(expression, expected) {
let actual;
for (let attempt = 0; attempt < 50; attempt++) {
actual = await read(expression);
if (actual === expected) return;
await delay(50);
}
assert.equal(actual, expected, expression);
}
try {
await app.whenReady();
if (process.platform === "darwin") {
app.setActivationPolicy("accessory");
app.dock.hide();
}
for (const [from, to] of [
["dark", "light"],
["light", "dark"],
]) {
nativeTheme.themeSource = from;
window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true });
await window.loadURL(
"data:text/html," +
encodeURIComponent(`<!doctype html>
<style>body { background: var(--background); } p { font: 20px system-ui; }</style>
<div id="root"></div>`),
);
await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8"));
window.showInactive();
const foreground =
"document.querySelector('p') && getComputedStyle(document.querySelector('p')).color";
const background = "getComputedStyle(document.body).backgroundColor";
await expectValue(foreground, colors[from].foreground);
await expectValue(background, colors[from].background);
window.hide();
await expectValue("document.visibilityState", "hidden");
nativeTheme.themeSource = to;
await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true);
// Reading the public runtime while hidden must not consume the pending theme notification.
assert.equal(await read("window.readThemeForTest()"), to);
window.showInactive();
await expectValue("document.visibilityState", "visible");
await expectValue(background, colors[to].background);
try {
await expectValue(foreground, colors[to].foreground);
} finally {
files.writeFileSync(
paths.join(artifactDir, `theme-${from}-to-${to}.png`),
(await window.webContents.capturePage()).toPNG(),
);
}
observations.push({
from,
to,
foreground: await read(foreground),
background: await read(background),
});
window.destroy();
window = undefined;
}
files.writeFileSync(
paths.join(artifactDir, "adaptive-theme.json"),
JSON.stringify(observations, null, 2),
);
app.exit(0);
} catch (error) {
console.error(error);
window?.destroy();
app.exit(1);
}
}
+8 -1
View File
@@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { chromium } from "playwright"; import { chromium } from "playwright";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
@@ -1241,6 +1242,7 @@ async function main() {
return; return;
} }
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
await runAppearanceFontSizeRegression(page); await runAppearanceFontSizeRegression(page);
const callerAgentId = await createCallerAgent(daemonPort); const callerAgentId = await createCallerAgent(daemonPort);
@@ -1265,7 +1267,12 @@ async function main() {
artifactDir, artifactDir,
}); });
const pluginLinks = await checkPluginLinks(); const pluginLinks = await checkPluginLinks();
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks }); writeJson(path.join(artifactDir, "result.json"), {
...report,
settingsMemory,
pluginLinks,
adaptiveTheme,
});
console.log( console.log(
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`, `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
); );
+29 -1
View File
@@ -4,6 +4,31 @@ import type { NodeEntrypointInvocation } from "../node-entrypoint-launcher.js";
import { createNodeEntrypointInvocation } from "../runtime-paths.js"; import { createNodeEntrypointInvocation } from "../runtime-paths.js";
import { resolveExternalCliEntrypoint } from "./entrypoints.js"; import { resolveExternalCliEntrypoint } from "./entrypoints.js";
/** A CLI command that exited non-zero, with the `--json` error it printed when there was one. */
export class ExternalCliCommandError extends Error {
readonly code: string | null;
readonly error: Record<string, unknown> | null;
constructor(message: string, error: Record<string, unknown> | null) {
super(message);
this.name = "ExternalCliCommandError";
this.error = error;
this.code = typeof error?.code === "string" ? error.code : null;
}
}
// `--json` commands print `{ "error": { code, message, ... } }` on stderr when they fail.
function parseJsonCliError(stderr: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(stderr) as { error?: unknown };
return typeof parsed.error === "object" && parsed.error !== null
? (parsed.error as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function createExternalCliInvocation(args: string[]): NodeEntrypointInvocation { function createExternalCliInvocation(args: string[]): NodeEntrypointInvocation {
return createNodeEntrypointInvocation({ return createNodeEntrypointInvocation({
entrypoint: resolveExternalCliEntrypoint(), entrypoint: resolveExternalCliEntrypoint(),
@@ -85,7 +110,10 @@ export async function runExternalCliJsonCommand(args: string[]): Promise<unknown
stderr: stderr.slice(0, 500), stderr: stderr.slice(0, 500),
command: invocation.command, command: invocation.command,
}); });
throw new Error(externalCliFailureMessage(result.exitCode, stdout, stderr)); throw new ExternalCliCommandError(
externalCliFailureMessage(result.exitCode, stdout, stderr),
parseJsonCliError(stderr),
);
} }
const stdout = result.stdout.trim(); const stdout = result.stdout.trim();
@@ -4,7 +4,13 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { createDaemonCommandHandlers } from "./daemon-manager"; import { ExternalCliCommandError } from "./cli/external";
import {
createDaemonCommandHandlers,
keepsTerminalsOnStop,
stopCapturedDaemon,
type DesktopDaemonStopReason,
} from "./daemon-manager";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
paseoHome: "", paseoHome: "",
@@ -73,7 +79,8 @@ vi.mock("./runtime-paths.js", () => ({
})), })),
})); }));
vi.mock("./cli/external.js", () => ({ vi.mock("./cli/external.js", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
runExternalCliJsonCommand: mocks.runExternalCliJsonCommand, runExternalCliJsonCommand: mocks.runExternalCliJsonCommand,
runExternalCliTextCommand: mocks.runExternalCliTextCommand, runExternalCliTextCommand: mocks.runExternalCliTextCommand,
})); }));
@@ -176,3 +183,107 @@ describe("daemon-manager commands", () => {
expect(await handler({ listen: "localhost:6799" })).toBeNull(); expect(await handler({ listen: "localhost:6799" })).toBeNull();
}); });
}); });
describe("desktop stops and terminals", () => {
const reasons: DesktopDaemonStopReason[] = [
"manual_ipc",
"settings",
"host_remove",
"quit",
"app_update",
"version_mismatch",
"restart",
];
it("keeps terminals only for an app update or version-mismatch restart, never on Windows", () => {
for (const platform of ["darwin", "linux"] as const) {
expect(reasons.filter((reason) => keepsTerminalsOnStop(reason, platform))).toEqual([
"app_update",
"version_mismatch",
]);
}
expect(reasons.filter((reason) => keepsTerminalsOnStop(reason, "win32"))).toEqual([]);
});
function stopHarness(runCliJson: (args: string[]) => Promise<unknown>) {
const stops: Array<{ keepTerminals: boolean | undefined }> = [];
const logged: string[] = [];
const instance = {
pid: 4242,
startedAt: "2026-10-21T00:00:00.000Z",
hostname: "host",
uid: 501,
listen: "127.0.0.1:6799",
};
const stopInstance = (async (
_home: string,
options: {
keepTerminals?: boolean;
requestShutdown?: (ready: { listen: string }) => Promise<void>;
},
) => {
stops.push({ keepTerminals: options.keepTerminals });
await options.requestShutdown?.({ listen: instance.listen });
return { action: "stopped", pid: instance.pid, forced: false, usedLifecycleRpc: true };
}) as unknown as NonNullable<Parameters<typeof stopCapturedDaemon>[0]["stopInstance"]>;
return {
stops,
logged,
input: {
home: "/tmp/paseo-home",
instance,
stopInstance,
runCliJson,
log: (message: string) => logged.push(message),
} as Omit<Parameters<typeof stopCapturedDaemon>[0], "reason" | "keepTerminals">,
};
}
it("asks the daemon to keep its terminals on an app update", async () => {
const calls: string[][] = [];
const harness = stopHarness(async (args) => {
calls.push(args);
return { action: "shutdown_requested" };
});
await stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true });
expect(harness.stops).toEqual([{ keepTerminals: true }]);
expect(calls).toEqual([
["daemon", "stop", "--host", "127.0.0.1:6799", "--keep-terminals", "--json"],
]);
});
it("stops a daemon without the capability with its terminals closed, and says so", async () => {
const calls: string[][] = [];
const harness = stopHarness(async (args) => {
calls.push(args);
if (args.includes("--keep-terminals")) {
throw new ExternalCliCommandError("refused", {
code: "TERMINALS_KEEP_UNSUPPORTED",
version: "0.11.0",
});
}
return { action: "shutdown_requested" };
});
await stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true });
expect(harness.stops).toEqual([{ keepTerminals: true }, { keepTerminals: false }]);
expect(calls[1]).toEqual(["daemon", "stop", "--host", "127.0.0.1:6799", "--json"]);
expect(harness.logged).toContain(
"daemon v0.11.0 cannot keep terminals; stopping it and closing them",
);
});
it("does not retry a keep stop that failed for another reason", async () => {
const harness = stopHarness(async () => {
throw new ExternalCliCommandError("unreachable", { code: "DAEMON_UNREACHABLE" });
});
await expect(
stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true }),
).rejects.toThrow("unreachable");
expect(harness.stops).toEqual([{ keepTerminals: true }]);
});
});
+78 -8
View File
@@ -37,7 +37,11 @@ import {
closeLocalTransportSession, closeLocalTransportSession,
} from "./local-transport.js"; } from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js"; import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js"; import {
ExternalCliCommandError,
runExternalCliJsonCommand,
runExternalCliTextCommand,
} from "./cli/external.js";
import { import {
createDesktopSettingsCommandHandlers, createDesktopSettingsCommandHandlers,
type DesktopCommandHandler, type DesktopCommandHandler,
@@ -348,18 +352,84 @@ export async function stopDesktopDaemon(
"Daemon changed since confirmation; inspect its current home and PID before stopping it.", "Daemon changed since confirmation; inspect its current home and PID before stopping it.",
); );
if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus(); if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus();
logDesktopDaemonLifecycle("stopping captured supervisor", { reason, pid: instance.pid, owned }); const keepTerminals = keepsTerminalsOnStop(reason, process.platform);
await stopDaemonInstance(home, { logDesktopDaemonLifecycle("stopping captured supervisor", {
instance, reason,
timeoutMs: 15_000, pid: instance.pid,
requestShutdown: async (ready) => { owned,
await runExternalCliJsonCommand(["daemon", "stop", "--host", ready.listen, "--json"]); keepTerminals,
},
}); });
await stopCapturedDaemon({ home, instance, reason, keepTerminals });
if (owned) ownedLaunch = null; if (owned) ownedLaunch = null;
return resolveDesktopDaemonStatus(); return resolveDesktopDaemonStatus();
} }
export interface StopCapturedDaemonInput {
home: string;
instance: DaemonInstance;
reason: DesktopDaemonStopReason;
keepTerminals: boolean;
stopInstance?: typeof stopDaemonInstance;
runCliJson?: typeof runExternalCliJsonCommand;
log?: typeof logDesktopDaemonLifecycle;
}
/** Stops the supervisor desktop captured, keeping its terminals when asked and supported. */
export async function stopCapturedDaemon(input: StopCapturedDaemonInput): Promise<void> {
const stopInstance = input.stopInstance ?? stopDaemonInstance;
const runCliJson = input.runCliJson ?? runExternalCliJsonCommand;
const logLifecycle = input.log ?? logDesktopDaemonLifecycle;
const stop = (keep: boolean) =>
stopInstance(input.home, {
instance: input.instance,
timeoutMs: 15_000,
keepTerminals: keep,
requestShutdown: async (ready) => {
await runCliJson([
"daemon",
"stop",
"--host",
ready.listen,
...(keep ? ["--keep-terminals"] : []),
"--json",
]);
},
});
try {
await stop(input.keepTerminals);
} catch (error) {
// The CLI refuses a keep stop before sending anything when the daemon predates the feature;
// that daemon closes terminals on every stop, as every upgrade did before.
if (
!input.keepTerminals ||
!(error instanceof ExternalCliCommandError) ||
error.code !== "TERMINALS_KEEP_UNSUPPORTED"
) {
throw error;
}
const version = typeof error.error?.version === "string" ? error.error.version : "unknown";
logLifecycle(`daemon v${version} cannot keep terminals; stopping it and closing them`, {
reason: input.reason,
pid: input.instance.pid,
});
await stop(false);
}
}
/**
* Whether a desktop stop keeps the daemon's terminals running for the daemon that replaces it.
* Only when one comes straight back (an app update, a version-mismatch restart), and never on
* Windows: a surviving terminal worker runs Paseo.exe and would lock the install folder the
* update replaces. Quit, Settings, host removal and manual stops close terminals.
*/
export function keepsTerminalsOnStop(
reason: DesktopDaemonStopReason,
platform: NodeJS.Platform,
): boolean {
if (platform === "win32") return false;
return reason === "app_update" || reason === "version_mismatch";
}
async function restartDaemon(): Promise<DesktopDaemonStatus> { async function restartDaemon(): Promise<DesktopDaemonStatus> {
await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]); await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]);
return resolveDesktopDaemonStatus(); return resolveDesktopDaemonStatus();
+5
View File
@@ -52,6 +52,11 @@
"types": "./dist/client/index.d.ts", "types": "./dist/client/index.d.ts",
"source": "./src/client/index.ts", "source": "./src/client/index.ts",
"default": "./dist/client/index.js" "default": "./dist/client/index.js"
},
"./internal/lifecycle": {
"types": "./dist/server/lifecycle.d.ts",
"source": "./src/server/lifecycle.ts",
"default": "./dist/server/lifecycle.js"
} }
}, },
"publishConfig": { "publishConfig": {
+61 -7
View File
@@ -13,6 +13,7 @@ const entries = {
"./server/provider": "server", "./server/provider": "server",
"./server/usage": "server", "./server/usage": "server",
"./server/acp": "server", "./server/acp": "server",
"./internal/lifecycle": "server",
"./client": "client", "./client": "client",
"./client/host": "client", "./client/host": "client",
"./client/ui": "client", "./client/ui": "client",
@@ -134,12 +135,8 @@ describe("plugin SDK import boundaries", () => {
it.each(Object.entries(entries))( it.each(Object.entries(entries))(
"%s respects its %s boundary, including type dependencies", "%s respects its %s boundary, including type dependencies",
(specifier, runtime) => { (specifier, runtime) => {
const name = const file = path.resolve(sourceDirectory, "..", manifest.exports[specifier].source);
specifier === "." expect(boundaryViolations(file, runtime)).toEqual([]);
? "index"
: specifier.slice(2) +
(specifier === "./client" || specifier === "./server" ? "/index" : "");
expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]);
}, },
); );
}); });
@@ -170,6 +167,7 @@ describe("plugin example import boundaries", () => {
const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries; const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries;
if ( if (
entry === "./client/host" || entry === "./client/host" ||
entry.startsWith("./internal/") ||
!(entry in entries) || !(entry in entries) ||
(entries[entry] !== "shared" && entries[entry] !== runtime) (entries[entry] !== "shared" && entries[entry] !== runtime)
) )
@@ -233,7 +231,7 @@ describe("plugin agent tools SDK boundary", () => {
}, },
}); });
export default function contribute(server: PluginServerContext) { export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => { server.addHook("agent.open.before", async ({ request }) => {
const title: string | null = request.title; const title: string | null = request.title;
// @ts-expect-error labels are read-only // @ts-expect-error labels are read-only
request.labels.x = "changed"; request.labels.x = "changed";
@@ -251,6 +249,62 @@ describe("plugin agent tools SDK boundary", () => {
}); });
}); });
describe("lifecycle hook SDK types", () => {
it("infers payloads and replacement requests and requires async handlers and new names", () => {
expect(
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
// @ts-expect-error Branch checkout has no branch-off base.
request.baseRef;
} else {
const base: string = request.baseRef;
}
const branch: string = request.branch;
// @ts-expect-error Repository identity is read-only.
request.repoRoot = "/other";
// @ts-expect-error Source checkout identity is read-only.
request.sourceCheckout = "/other";
if (request.kind === "checkout-change-request") {
const number: number = request.changeRequestNumber;
}
return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" };
});
server.addHook("worktree.create.before", async () => {});
server.addHook("worktree.create.after", async ({ worktree }) => {
const path: string = worktree.path;
const branch: string = worktree.branch;
const source: string = worktree.sourceCheckout;
// @ts-expect-error Worktree facts have no workspace identity.
worktree.workspaceId;
});
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
server.addHook("workspace.create.before", async () => {});
server.addHook("agent.open.before", async () => {});
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Notification payloads have no creation request.
outcome.request;
});
// @ts-expect-error Async handlers are required even for notifications.
server.addHook("agent.turn.before", () => {});
// @ts-expect-error Legacy names are only accepted by deprecated registration methods.
server.addHook("agent.session_open", async () => {});
// @ts-expect-error Unknown names fail typechecking.
server.addHook("nonsense", async () => {});
// @ts-expect-error A prepare handler must return the whole request or undefined.
server.addHook("agent.create.before", async () => ({ invalid: true }));
server.before("agent.session_open", ({ request }) => request);
server.on("agent.turn_ended", () => {});
return () => {};
}
`),
).toEqual([]);
});
});
describe("agent session reload SDK boundary", () => { describe("agent session reload SDK boundary", () => {
it("exposes reload separately from snapshot refresh", () => { it("exposes reload separately from snapshot refresh", () => {
expect( expect(
+7
View File
@@ -13,8 +13,15 @@ export type {
PluginSettingsState, PluginSettingsState,
} from "./contracts.js"; } from "./contracts.js";
export type { export type {
PluginHooks,
PluginHookEvents,
PluginHookRequests,
PluginHookPayload,
PluginHookResult,
PluginHookContext, PluginHookContext,
PluginHookWorkspace, PluginHookWorkspace,
PluginHookWorktree,
PluginWorktreeCreateRequest,
PluginHookAgent, PluginHookAgent,
PluginSessionOpenRequest, PluginSessionOpenRequest,
PluginTurnOutcome, PluginTurnOutcome,
+153 -14
View File
@@ -21,6 +21,46 @@ export interface PluginHookWorkspace {
archivedAt: string | null; archivedAt: string | null;
} }
export interface PluginHookWorktree {
path: string;
branch: string;
sourceCheckout: string;
}
/** Resolved creation options before Git fetches refs or chooses a unique branch name. */
export type PluginWorktreeCreateRequest = {
readonly repoRoot: string;
readonly sourceCheckout: string;
branch: string;
slug: string;
path: string;
} & (
| { kind: "branch-off"; baseRef: string }
| { kind: "checkout-branch" }
| {
kind: "checkout-change-request";
baseRef: string;
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
headRepository?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
baseRef: string;
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
);
export interface PluginHookAgent { export interface PluginHookAgent {
id: string; id: string;
workspaceId: string | null; workspaceId: string | null;
@@ -49,9 +89,9 @@ export type PluginTurnOutcome =
| { kind: "failed"; error: { message: string; code?: string } } | { kind: "failed"; error: { message: string; code?: string } }
| { kind: "canceled"; reason: string }; | { kind: "canceled"; reason: string };
export interface PluginLifecycleEvents { export interface PluginHookEvents {
"agent.turn_started": { agent: PluginHookAgent; turnId: string | null }; "agent.turn.before": { agent: PluginHookAgent; turnId: string | null };
"agent.turn_ended": { "agent.turn.after": {
agent: PluginHookAgent; agent: PluginHookAgent;
turnId: string | null; turnId: string | null;
outcome: PluginTurnOutcome; outcome: PluginTurnOutcome;
@@ -61,26 +101,58 @@ export interface PluginLifecycleEvents {
*/ */
timeline: readonly AgentTimelineItem[]; timeline: readonly AgentTimelineItem[];
}; };
"agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest }; "agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission_resolved": { "agent.permission.after": {
agent: PluginHookAgent; agent: PluginHookAgent;
requestId: string; requestId: string;
resolution: AgentPermissionResponse; resolution: AgentPermissionResponse;
}; };
"agent.archived": { agent: PluginHookAgent; archivedAt: string }; "agent.archive.after": { agent: PluginHookAgent; archivedAt: string };
"agent.created": { agent: PluginHookAgent }; "agent.create.after": { agent: PluginHookAgent };
"agent.closed": { agent: PluginHookAgent }; "agent.close.after": { agent: PluginHookAgent };
"workspace.created": { workspace: PluginHookWorkspace }; "workspace.create.after": { workspace: PluginHookWorkspace };
"workspace.archived": { workspace: PluginHookWorkspace }; "workspace.archive.after": { workspace: PluginHookWorkspace };
} }
export interface PluginBeforeRequests { export interface PluginHookRequests {
"agent.create": { config: AgentSessionConfig; env?: Record<string, string> }; "worktree.create.before": PluginWorktreeCreateRequest;
"agent.session_open": PluginSessionOpenRequest; "agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
"workspace.create": Omit<WorkspaceCreateRequest, "type" | "requestId">; "agent.open.before": PluginSessionOpenRequest;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
} }
export type PluginHooks = {
[Name in keyof PluginHookRequests]: {
request: PluginHookRequests[Name];
result: PluginHookRequests[Name];
};
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
};
export type PluginHookPayload<Name extends keyof PluginHooks> = {
[Key in keyof PluginHooks]: PluginHooks[Key] extends { request: infer Request }
? { request: Request }
: PluginHooks[Key] extends { payload: infer Payload }
? Payload
: never;
}[Name];
export type PluginHookResult<Name extends keyof PluginHooks> = {
[Key in keyof PluginHooks]: PluginHooks[Key] extends { result: infer Result }
? Result | void
: void;
}[Name];
type HookHandlers = {
[Name in keyof PluginHooks]: (
payload: PluginHookPayload<Name>,
context: PluginHookContext,
) => Promise<PluginHookResult<Name>>;
};
export interface PluginLifecycleRegistration { export interface PluginLifecycleRegistration {
addHook<Name extends keyof PluginHooks>(name: Name, handler: HookHandlers[Name]): () => void;
/** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */
on<Name extends keyof PluginLifecycleEvents>( on<Name extends keyof PluginLifecycleEvents>(
name: Name, name: Name,
handler: ( handler: (
@@ -88,6 +160,7 @@ export interface PluginLifecycleRegistration {
context: PluginHookContext, context: PluginHookContext,
) => void | Promise<void>, ) => void | Promise<void>,
): () => void; ): () => void;
/** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */
before<Name extends keyof PluginBeforeRequests>( before<Name extends keyof PluginBeforeRequests>(
name: Name, name: Name,
handler: ( handler: (
@@ -96,3 +169,69 @@ export interface PluginLifecycleRegistration {
) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>, ) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>,
): () => void; ): () => void;
} }
/** @internal Lifecycle policy shared by the daemon and plugin process. */
export const pluginHookRegistry = {
"workspace.create.before": { waits: true },
"workspace.create.after": { waits: false },
"workspace.archive.after": { waits: false },
"worktree.create.before": { waits: true },
"worktree.create.after": { waits: true },
"agent.create.before": { waits: true },
"agent.create.after": { waits: false },
"agent.open.before": { waits: true },
"agent.close.after": { waits: false },
"agent.archive.after": { waits: false },
"agent.turn.before": { waits: false },
"agent.turn.after": { waits: false },
"agent.permission.before": { waits: false },
"agent.permission.after": { waits: false },
} as const satisfies Record<keyof PluginHooks, { waits: boolean }>;
// COMPAT(pluginHookNames): added in v0.14, remove after 2027-04-30
const legacyEvents = {
"workspace.created": "workspace.create.after",
"workspace.archived": "workspace.archive.after",
"agent.created": "agent.create.after",
"agent.closed": "agent.close.after",
"agent.archived": "agent.archive.after",
"agent.turn_started": "agent.turn.before",
"agent.turn_ended": "agent.turn.after",
"agent.permission_requested": "agent.permission.before",
"agent.permission_resolved": "agent.permission.after",
} as const;
const legacyRequests = {
"workspace.create": "workspace.create.before",
"agent.create": "agent.create.before",
"agent.session_open": "agent.open.before",
} as const;
export type PluginLifecycleEvents = {
[Name in keyof typeof legacyEvents]: PluginHookEvents[(typeof legacyEvents)[Name]];
};
export type PluginBeforeRequests = {
[Name in keyof typeof legacyRequests]: PluginHookRequests[(typeof legacyRequests)[Name]];
};
/** @internal SDK adapter for the daemon's hook registry. */
export function createLifecycleRegistration(
register: (
name: keyof PluginHooks,
handler: (input: never, context: PluginHookContext) => unknown,
) => () => void,
): PluginLifecycleRegistration {
const names = new Set<string>(Object.keys(pluginHookRegistry));
return {
addHook(name, handler) {
if (!names.has(name)) throw new Error(`Unknown lifecycle hook: ${name}`);
return register(name, handler);
},
on(name, handler) {
if (!Object.hasOwn(legacyEvents, name)) throw new Error(`Unknown lifecycle event: ${name}`);
return register(legacyEvents[name], handler);
},
before(name, handler) {
if (!Object.hasOwn(legacyRequests, name)) throw new Error(`Unknown before hook: ${name}`);
return register(legacyRequests[name], handler);
},
};
}
@@ -23,6 +23,29 @@ describe("plugin protocol compatibility", () => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
}, },
); );
it.each([
{ type: "plugin.catalog.get.request", requestId: "catalog" },
{
type: "plugin.catalog.get.request",
requestId: "catalog",
cachedClientBundles: [{ id: "example", clientBundleHash: "a".repeat(64) }],
},
])("parses plugin catalog requests %j", (request) => {
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
});
it("parses plugin catalogs that leave out a client bundle the client already holds", () => {
const message = {
type: "plugin.catalog.get.response",
payload: {
requestId: "catalog",
plugins: [
{ id: "cached", clientBundleHash: "a".repeat(64) },
{ id: "changed", clientBundle: "bundle", clientBundleHash: "b".repeat(64) },
],
},
};
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
it("preserves manifest metadata in plugin list responses", () => { it("preserves manifest metadata in plugin list responses", () => {
const message = { const message = {
type: "plugin.list.response", type: "plugin.list.response",
+39 -1
View File
@@ -237,6 +237,9 @@ export const MutableDaemonConfigSchema = z
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported. // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported.
// Persisted host-wide value, applied live by the daemon. // Persisted host-wide value, applied live by the daemon.
idleRuntimeCleanup: z.boolean().optional(), idleRuntimeCleanup: z.boolean().optional(),
// COMPAT(restartRecovery): added in v0.14.0, keep optional for older hosts.
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
appendSystemPrompt: z.string().default(""), appendSystemPrompt: z.string().default(""),
terminalProfiles: z.array(TerminalProfileSchema).optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(),
@@ -262,6 +265,8 @@ export const MutableDaemonConfigPatchSchema = z
autoArchiveAfterMerge: z.boolean().optional(), autoArchiveAfterMerge: z.boolean().optional(),
enableTerminalAgentHooks: z.boolean().optional(), enableTerminalAgentHooks: z.boolean().optional(),
idleRuntimeCleanup: z.boolean().optional(), idleRuntimeCleanup: z.boolean().optional(),
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
appendSystemPrompt: z.string().optional(), appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(),
@@ -1470,6 +1475,10 @@ export const DiagnosticsRequestSchema = z.object({
export const PluginCatalogGetRequestSchema = z.object({ export const PluginCatalogGetRequestSchema = z.object({
type: z.literal("plugin.catalog.get.request"), type: z.literal("plugin.catalog.get.request"),
requestId: z.string(), requestId: z.string(),
/** Client bundles the client already holds; the daemon leaves out each one whose hash matches. */
cachedClientBundles: z
.array(z.object({ id: PluginIdSchema, clientBundleHash: z.string() }))
.optional(),
}); });
export const PluginListRequestSchema = z.object({ export const PluginListRequestSchema = z.object({
@@ -1892,11 +1901,17 @@ export const RestartServerRequestMessageSchema = z.object({
type: z.literal("restart_server_request"), type: z.literal("restart_server_request"),
reason: z.string().optional(), reason: z.string().optional(),
requestId: z.string(), requestId: z.string(),
// Restart keeps terminals unless this is true. Daemons that predate
// `features.terminalsSurviveRestart` ignore it and close terminals anyway.
killTerminals: z.boolean().optional(),
}); });
export const ShutdownServerRequestMessageSchema = z.object({ export const ShutdownServerRequestMessageSchema = z.object({
type: z.literal("shutdown_server_request"), type: z.literal("shutdown_server_request"),
requestId: z.string(), requestId: z.string(),
// Shutdown closes terminals unless this is true. Gate on
// `features.terminalsSurviveRestart`: older daemons drop the field.
keepTerminals: z.boolean().optional(),
}); });
export const DaemonUpdateRequestMessageSchema = z.object({ export const DaemonUpdateRequestMessageSchema = z.object({
@@ -3721,6 +3736,8 @@ export const ServerInfoStatusPayloadSchema = z
skillManagement: z.boolean().optional(), skillManagement: z.boolean().optional(),
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
idleRuntimeCleanupSetting: z.boolean().optional(), idleRuntimeCleanupSetting: z.boolean().optional(),
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
resumeOnStartupSetting: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(), "terminal-restore-modes": z.boolean().optional(),
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
@@ -3759,6 +3776,8 @@ export const ServerInfoStatusPayloadSchema = z
daemonDiagnostics: z.boolean().optional(), daemonDiagnostics: z.boolean().optional(),
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
daemonSelfUpdate: z.boolean().optional(), daemonSelfUpdate: z.boolean().optional(),
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
terminalsSurviveRestart: z.boolean().optional(),
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: z.boolean().optional(), agentForkContext: z.boolean().optional(),
// COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14.
@@ -5121,6 +5140,22 @@ export const DaemonGetStatusResponseSchema = z.object({
error: z.string().nullable().optional(), error: z.string().nullable().optional(),
}), }),
), ),
// Processes a terminal cleanup on this host (a kill restart or stop) could not confirm
// gone; empty when every cleanup finished. Daemons without terminalsSurviveRestart have
// no such cleanup and omit it.
terminalCleanupUnconfirmed: z
.array(
z.object({
pid: z.number(),
role: z.string(),
reason: z.string(),
detail: z.string(),
}),
)
.optional(),
// Set when this host's terminals are held by a terminal worker from a newer Paseo, which
// this daemon leaves running but cannot drive: why, and what the user can do.
terminalsUnavailable: z.string().optional(),
}) })
.passthrough(), .passthrough(),
}); });
@@ -6757,7 +6792,10 @@ export const PluginCatalogGetResponseSchema = z.object({
plugins: z.array( plugins: z.array(
z.object({ z.object({
id: PluginIdSchema, id: PluginIdSchema,
clientBundle: z.string(), /** Left out only when the request listed this plugin with the same `clientBundleHash`. */
clientBundle: z.string().optional(),
/** SHA-256 of `clientBundle`, hex encoded. */
clientBundleHash: z.string().optional(),
requirements: PluginRequirementsSchema.optional(), requirements: PluginRequirementsSchema.optional(),
}), }),
), ),
+2 -2
View File
@@ -73,7 +73,7 @@
"clean": "node ../../scripts/clean-package-dist.mjs", "clean": "node ../../scripts/clean-package-dist.mjs",
"build": "npm run build:lib && npm run build:scripts", "build": "npm run build:lib && npm run build:scripts",
"build:clean": "npm run clean && npm run build", "build:clean": "npm run clean && npm run build",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).some((segment)=>segment==='node_modules'||segment==='test')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node scripts/build-terminal-worker.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).some((segment)=>segment==='node_modules'||segment==='test')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui",
"start": "node dist/scripts/supervisor-entrypoint.js", "start": "node dist/scripts/supervisor-entrypoint.js",
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration", "test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e", "test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts", "test:integration:real": "vitest run real.e2e.test.ts",
@@ -0,0 +1,55 @@
// Bundles the terminal worker into one file with its shell integration beside it. The daemon
// copies this directory (plus node-pty) into $PASEO_HOME/terminal-worker/ and launches the worker
// from the copy, so a worker that outlives an upgrade never loads files from the installation
// that replaced the one it came from. See docs/terminal-worker.md.
//
// Usage: node scripts/build-terminal-worker.mjs [--outdir <dir>]
// Default output: dist/server/terminal-worker/
import { cp, mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "esbuild";
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
export const TERMINAL_WORKER_BUNDLE_NAME = "terminal-worker.mjs";
export async function buildTerminalWorkerBundle({ outDir }) {
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
await build({
entryPoints: [path.join(packageRoot, "src/terminal/terminal-worker-process.ts")],
bundle: true,
format: "esm",
platform: "node",
target: "node20",
// Loaded from the copy's own node_modules/ at run time.
external: ["node-pty"],
// Bundled CommonJS dependencies call require() for Node built-ins.
banner: {
js: 'import { createRequire as __terminalWorkerCreateRequire } from "node:module"; const require = __terminalWorkerCreateRequire(import.meta.url);',
},
outfile: path.join(outDir, TERMINAL_WORKER_BUNDLE_NAME),
logLevel: "warning",
});
// terminal.ts resolves the zsh integration relative to its own module, which is the bundle.
await cp(
path.join(packageRoot, "src/terminal/shell-integration"),
path.join(outDir, "shell-integration"),
{ recursive: true },
);
}
function readOutDir(argv) {
const index = argv.indexOf("--outdir");
if (index === -1) return path.join(packageRoot, "dist/server/terminal-worker");
const value = argv[index + 1];
if (!value) throw new Error("--outdir requires a directory");
return path.resolve(value);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await buildTerminalWorkerBundle({ outDir: readOutDir(process.argv.slice(2)) });
}
@@ -12,6 +12,7 @@ import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { daemonLogPath } from "../src/server/daemon-instance.js"; import { daemonLogPath } from "../src/server/daemon-instance.js";
import { PRIVATE_FILE_MODE } from "../src/server/private-files.js"; import { PRIVATE_FILE_MODE } from "../src/server/private-files.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { reapTerminalWorkers } from "../src/terminal/terminal-worker-reaper.js";
import { runSupervisor } from "./supervisor.js"; import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
@@ -175,6 +176,10 @@ async function main(): Promise<void> {
onWorkerExit: () => onWorkerExit: () =>
updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }), updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }),
onSupervisorExit: releaseLock, onSupervisorExit: releaseLock,
cleanupTerminals: async (log) => {
const result = await reapTerminalWorkers({ paseoHome, log });
log("Terminal worker cleanup result", { ...result });
},
}); });
requestSupervisorShutdown = supervisor.requestShutdown; requestSupervisorShutdown = supervisor.requestShutdown;
} }
@@ -0,0 +1,180 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
// A real supervisor with scripted workers. Each worker generation logs what it sees to an
// events file; `cleanupTerminals` logs "cleanup". The order of events is the contract: a kill
// intent cleans up before the next worker starts, a keep intent or a crash never cleans up.
async function runSupervisorWith(workerScript: string, timeoutMs = 8_000) {
const home = await mkdtemp(path.join(tmpdir(), "paseo-terminal-cleanup-"));
const workerPath = path.join(home, "worker.mjs");
const runnerPath = path.join(home, "runner.mjs");
const eventsPath = path.join(home, "events.jsonl");
const supervisor = new URL("./supervisor.ts", import.meta.url).href;
await writeFile(
workerPath,
`
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
const counter = ${JSON.stringify(path.join(home, "generation"))};
const generation = existsSync(counter) ? Number(readFileSync(counter, "utf8")) + 1 : 1;
writeFileSync(counter, String(generation));
const record = (event) =>
appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify(event) + "\\n");
record({ started: generation });
${workerScript}
`,
);
await writeFile(
runnerPath,
`
import { appendFileSync } from "node:fs";
import { runSupervisor } from ${JSON.stringify(supervisor)};
runSupervisor({
name: "TerminalCleanupTest", startupMessage: "starting", restartOnCrash: true,
resolveWorkerEntry: () => ${JSON.stringify(workerPath)}, workerArgs: [], workerExecArgv: [],
cleanupTerminals: async () => {
appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify("cleanup") + "\\n");
},
});
`,
);
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
env: { ...env, HOME: home, PASEO_HOME: home },
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk) => (output += chunk));
child.stderr.on("data", (chunk) => (output += chunk));
try {
const code = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(output));
}, timeoutMs);
child.once("error", reject);
child.once("close", (exitCode) => {
clearTimeout(timer);
resolve(exitCode);
});
});
const events = (await readFile(eventsPath, "utf8").catch(() => ""))
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as unknown);
return { code, output, events };
} finally {
await rm(home, { recursive: true, force: true });
}
}
// Generation 1 asks for `firstIntent`; generation 2 (if any) stops with `secondIntent`.
function scriptedWorker(firstIntent: string, secondIntent = "") {
return `
process.on("message", (message) => {
if (message.type !== "paseo:graceful-shutdown") return;
record({ graceful: generation, keepTerminals: message.keepTerminals });
process.exit(0);
});
process.send({ type: "paseo:ready", listen: "endpoint-" + generation, serverId: "srv" });
if (generation === 1) { ${firstIntent} }
else { ${secondIntent} }
`;
}
const stopKeeping = `process.send({ type: "paseo:shutdown", reason: "test", keepTerminals: true });`;
describe("supervisor terminal cleanup", () => {
test("a keep restart passes keepTerminals and never cleans up", async () => {
const result = await runSupervisorWith(
scriptedWorker(
`process.send({ type: "paseo:restart", reason: "test", keepTerminals: true });`,
stopKeeping,
),
);
expect(result.code, result.output).toBe(0);
expect(result.events).toEqual([
{ started: 1 },
{ graceful: 1, keepTerminals: true },
{ started: 2 },
{ graceful: 2, keepTerminals: true },
]);
});
test("a kill restart cleans up after the worker exits and before the respawn", async () => {
const result = await runSupervisorWith(
scriptedWorker(
`process.send({ type: "paseo:restart", reason: "test", keepTerminals: false });`,
stopKeeping,
),
);
expect(result.code, result.output).toBe(0);
expect(result.events).toEqual([
{ started: 1 },
{ graceful: 1, keepTerminals: false },
"cleanup",
{ started: 2 },
{ graceful: 2, keepTerminals: true },
]);
});
test("a kill restart whose worker hangs cleans up after the forced kill, before the respawn", async () => {
const result = await runSupervisorWith(
`
process.on("message", (message) => {
if (message.type !== "paseo:graceful-shutdown") return;
record({ graceful: generation, keepTerminals: message.keepTerminals });
if (generation === 2) process.exit(0);
});
process.send({ type: "paseo:ready", listen: "endpoint-" + generation, serverId: "srv" });
if (generation === 1) {
process.send({ type: "paseo:restart", reason: "test", keepTerminals: false });
setInterval(() => {}, 1000);
} else {
${stopKeeping}
}
`,
20_000,
);
expect(result.code, result.output).toBe(0);
expect(result.events).toEqual([
{ started: 1 },
{ graceful: 1, keepTerminals: false },
"cleanup",
{ started: 2 },
{ graceful: 2, keepTerminals: true },
]);
}, 30_000);
test("a crash restart keeps terminals", async () => {
const result = await runSupervisorWith(scriptedWorker(`process.exit(1);`, stopKeeping));
expect(result.code, result.output).toBe(0);
expect(result.events).toEqual([
{ started: 1 },
{ started: 2 },
{ graceful: 2, keepTerminals: true },
]);
});
test("a kill stop cleans up when the supervisor exits", async () => {
const result = await runSupervisorWith(
scriptedWorker(
`process.send({ type: "paseo:shutdown", reason: "test", keepTerminals: false });`,
),
);
expect(result.code, result.output).toBe(0);
expect(result.events).toEqual([
{ started: 1 },
{ graceful: 1, keepTerminals: false },
"cleanup",
]);
});
});
+75 -17
View File
@@ -21,6 +21,7 @@ type WorkerLifecycleMessage =
| { | {
type: "paseo:shutdown"; type: "paseo:shutdown";
reason?: string; reason?: string;
keepTerminals: boolean;
} }
| { | {
type: "paseo:ready"; type: "paseo:ready";
@@ -30,6 +31,7 @@ type WorkerLifecycleMessage =
| { | {
type: "paseo:restart"; type: "paseo:restart";
reason?: string; reason?: string;
keepTerminals: boolean;
}; };
interface SupervisorHeartbeatMessage { interface SupervisorHeartbeatMessage {
@@ -39,8 +41,11 @@ interface SupervisorHeartbeatMessage {
interface SupervisorGracefulShutdownMessage { interface SupervisorGracefulShutdownMessage {
type: "paseo:graceful-shutdown"; type: "paseo:graceful-shutdown";
reason: string; reason: string;
keepTerminals: boolean;
} }
export type SupervisorLifecycleLog = (message: string, fields?: Record<string, unknown>) => void;
interface SupervisorOptions { interface SupervisorOptions {
name: string; name: string;
startupMessage: string; startupMessage: string;
@@ -57,11 +62,15 @@ interface SupervisorOptions {
onWorkerExit?: () => Promise<void> | void; onWorkerExit?: () => Promise<void> | void;
restartOnCrash?: boolean; restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void; onSupervisorExit?: () => Promise<void> | void;
// Closes every terminal the worker left. Runs after a kill-intent restart or stop once the
// worker has exited (also after a forced kill), before a respawn, and on any supervisor
// exit except a keep-terminals stop. A crash restart keeps terminals and skips it.
cleanupTerminals?: (log: SupervisorLifecycleLog) => Promise<void>;
logFile?: SupervisorLogFileOptions; logFile?: SupervisorLogFileOptions;
} }
export interface SupervisorController { export interface SupervisorController {
requestShutdown(reason: string): void; requestShutdown(reason: string, options?: { keepTerminals?: boolean }): void;
} }
function describeExit(code: number | null, signal: NodeJS.Signals | null): string { function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
@@ -74,10 +83,11 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
} }
const type = (msg as { type?: unknown }).type; const type = (msg as { type?: unknown }).type;
if (type === "paseo:shutdown") { if (type === "paseo:shutdown") {
const reason = (msg as { reason?: unknown }).reason; const { reason, keepTerminals } = msg as { reason?: unknown; keepTerminals?: unknown };
return { return {
type: "paseo:shutdown", type: "paseo:shutdown",
...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}),
keepTerminals: keepTerminals === true,
}; };
} }
if (type === "paseo:ready") { if (type === "paseo:ready") {
@@ -91,10 +101,11 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
return { type: "paseo:ready", listen, serverId }; return { type: "paseo:ready", listen, serverId };
} }
if (type === "paseo:restart") { if (type === "paseo:restart") {
const reason = (msg as { reason?: unknown }).reason; const { reason, keepTerminals } = msg as { reason?: unknown; keepTerminals?: unknown };
return { return {
type: "paseo:restart", type: "paseo:restart",
...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}),
keepTerminals: keepTerminals === true,
}; };
} }
return null; return null;
@@ -185,6 +196,9 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
let child: ChildProcess | null = null; let child: ChildProcess | null = null;
let restarting = false; let restarting = false;
let shuttingDown = false; let shuttingDown = false;
// The resolved terminal decision of the pending restart or stop. It survives the force-kill
// path, so a hung kill restart still closes terminals before the respawn.
let pendingKeepTerminals = false;
let exiting = false; let exiting = false;
let lifecycleFailed = false; let lifecycleFailed = false;
let publication = Promise.resolve(); let publication = Promise.resolve();
@@ -217,12 +231,31 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
const closeLogStream = (): Promise<void> => durableLog.close(); const closeLogStream = (): Promise<void> => durableLog.close();
const exitSupervisor = (code: number): void => { const runTerminalCleanup = async (when: string): Promise<void> => {
if (!options.cleanupTerminals) {
return;
}
writeLifecycleLog("Closing terminals", { when });
try {
await options.cleanupTerminals(writeLifecycleLog);
writeLifecycleLog("Terminal cleanup finished", { when });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Terminal cleanup not confirmed (${when}): ${message}`);
}
};
const exitSupervisor = (code: number, exitOptions: { keepTerminals?: boolean } = {}): void => {
if (exiting) { if (exiting) {
return; return;
} }
exiting = true; exiting = true;
Promise.resolve(options.onSupervisorExit?.()) const cleanup =
exitOptions.keepTerminals === true
? Promise.resolve()
: runTerminalCleanup("supervisor_exit");
cleanup
.then(() => options.onSupervisorExit?.())
.catch((error) => { .catch((error) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
log(`Supervisor exit cleanup failed: ${message}`); log(`Supervisor exit cleanup failed: ${message}`);
@@ -357,14 +390,20 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
if (lifecycleMessage.type === "paseo:shutdown") { if (lifecycleMessage.type === "paseo:shutdown") {
const reason = lifecycleMessage.reason ?? "worker_requested_shutdown"; const reason = lifecycleMessage.reason ?? "worker_requested_shutdown";
writeLifecycleLog("Worker requested shutdown", { reason }); writeLifecycleLog("Worker requested shutdown", {
requestShutdown(reason); reason,
keepTerminals: lifecycleMessage.keepTerminals,
});
requestShutdown(reason, { keepTerminals: lifecycleMessage.keepTerminals });
return; return;
} }
const reason = lifecycleMessage.reason ?? "worker_requested_restart"; const reason = lifecycleMessage.reason ?? "worker_requested_restart";
writeLifecycleLog("Worker requested restart", { reason }); writeLifecycleLog("Worker requested restart", {
requestRestart(reason); reason,
keepTerminals: lifecycleMessage.keepTerminals,
});
requestRestart(reason, lifecycleMessage.keepTerminals);
}); });
child.on("error", (error) => { child.on("error", (error) => {
@@ -395,7 +434,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
if (shuttingDown) { if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
exitSupervisor(0); exitSupervisor(0, { keepTerminals: pendingKeepTerminals });
return; return;
} }
@@ -404,13 +443,25 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM"));
if (restarting || crashed) { if (restarting || crashed) {
const closeTerminals = restarting && !pendingKeepTerminals;
restarting = false; restarting = false;
pendingKeepTerminals = false;
log( log(
crashed crashed && !closeTerminals
? `Worker crashed (${exitDescriptor}). Restarting worker...` ? `Worker crashed (${exitDescriptor}). Restarting worker...`
: `Worker exited (${exitDescriptor}). Restarting worker...`, : `Worker exited (${exitDescriptor}). Restarting worker...`,
); );
spawnWorker(); if (!closeTerminals) {
spawnWorker();
return;
}
// The replacement daemon must never adopt terminals the user asked to close.
void runTerminalCleanup("before_respawn").then(() => {
if (!shuttingDown) {
spawnWorker();
}
return;
});
return; return;
} }
@@ -429,9 +480,11 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
const message: SupervisorGracefulShutdownMessage = { const message: SupervisorGracefulShutdownMessage = {
type: "paseo:graceful-shutdown", type: "paseo:graceful-shutdown",
reason, reason,
keepTerminals: pendingKeepTerminals,
}; };
writeLifecycleLog("Supervisor requesting graceful worker shutdown", { writeLifecycleLog("Supervisor requesting graceful worker shutdown", {
reason, reason,
keepTerminals: pendingKeepTerminals,
supervisorPid: process.pid, supervisorPid: process.pid,
workerPid: currentChild.pid ?? null, workerPid: currentChild.pid ?? null,
}); });
@@ -455,28 +508,33 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
}); });
}; };
const requestRestart = (reason: string) => { const requestRestart = (reason: string, keepTerminals: boolean) => {
if (!child || restarting || shuttingDown) { if (!child || restarting || shuttingDown) {
return; return;
} }
restarting = true; restarting = true;
writeLifecycleLog("Restart requested", { reason }); pendingKeepTerminals = keepTerminals;
writeLifecycleLog("Restart requested", { reason, keepTerminals });
log(`${reason}. Stopping worker for restart...`); log(`${reason}. Stopping worker for restart...`);
requestWorkerShutdown(reason); requestWorkerShutdown(reason);
scheduleForceKill(reason); scheduleForceKill(reason);
}; };
const requestShutdown = (reason: string) => { const requestShutdown = (reason: string, shutdownOptions: { keepTerminals?: boolean } = {}) => {
if (shuttingDown) { if (shuttingDown) {
return; return;
} }
shuttingDown = true; shuttingDown = true;
restarting = false; restarting = false;
writeLifecycleLog("Supervisor shutdown requested", { reason }); pendingKeepTerminals = shutdownOptions.keepTerminals === true;
writeLifecycleLog("Supervisor shutdown requested", {
reason,
keepTerminals: pendingKeepTerminals,
});
log(`${reason}. Stopping worker...`); log(`${reason}. Stopping worker...`);
if (!child) { if (!child) {
void publication.then(() => { void publication.then(() => {
exitSupervisor(lifecycleFailed ? 1 : 0); exitSupervisor(lifecycleFailed ? 1 : 0, { keepTerminals: pendingKeepTerminals });
return; return;
}); });
return; return;
@@ -1,17 +1,10 @@
import { createRequire } from "node:module";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { delimiter, resolve } from "node:path"; import { delimiter, resolve } from "node:path";
import type { ProcessEnvRecord } from "../server/paseo-env.js"; import type { ProcessEnvRecord } from "../server/paseo-env.js";
import { execCommand } from "../utils/spawn.js"; import { execCommand } from "../utils/spawn.js";
import which from "which";
import { windowsExecutableResolution } from "./windows.js"; import { windowsExecutableResolution } from "./windows.js";
type Which = (
command: string,
options: { all: true; path?: string; pathExt?: string },
) => Promise<string[]>;
const require = createRequire(import.meta.url);
const which = require("which") as Which;
const PROBE_TIMEOUT_MS = 2000; const PROBE_TIMEOUT_MS = 2000;
function hasPathSeparator(value: string): boolean { function hasPathSeparator(value: string): boolean {

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