Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 41d9bb4294 fix(app): preserve socket capabilities on adopted probe clients 2026-10-30 17:56:57 +01:00
Mohamed Boudra b044201f62 fix(transport): preserve native admission and pace file writes
Sockets without a drain signal retain single-frame outbound admission while accepting chunked responses. Uploads and response-bearing control sends must observe queued admission failures instead of waiting for a response that cannot arrive.
2026-10-30 17:41:57 +01:00
Mohamed Boudra cacf186a94 fix(transport): keep listener errors outside framing validation
Only malformed wire data should close a connection as a protocol violation. Delivery exceptions must leave framing state usable for subsequent messages.
2026-10-30 17:23:27 +01:00
Mohamed Boudra 47b18ffb50 fix(transport): isolate rejected sends from sibling routes
Capacity rejection before the first frame is admitted must leave the shared link usable. Observe queued RPC admission failures while keeping response deadlines active during drain.
2026-10-30 17:06:29 +01:00
Mohamed Boudra ab55377458 test(transport): preserve large coverage with bounded timeline rows
Build a response above 50 MiB from rows below the existing content budget. Make the deferred draft-publication journey select a model before submitting so it does not test unresolved provider selection.
2026-10-30 16:33:13 +01:00
Mohamed Boudra f3d8718c6a Merge main into transparent transport chunking 2026-10-30 16:25:23 +01:00
Mohamed Boudra 26db1a4672 fix(transport): account for exact bytes and terminate failed framing
Bound reassembly by a growing byte buffer instead of retaining an object for every chunk. Notify socket owners of fatal sends even when source delivery discards the promise. Repair the ambiguous composer selector and give its multi-workspace journey enough time to complete.
2026-10-30 16:25:21 +01:00
Mohamed Boudra f28f0fee3d fix(transport): preserve backpressure across framing and routes
Ordinary daemon writes must remain visible to terminal catch-up and socket admission. Pace chunked writes and explicit completion requests, include waiting logical messages in pressure readings, and propagate origin completion through routed client transports.
2026-10-30 15:51:50 +01:00
Mohamed Boudra 98f667f93e fix(transport): bound logical messages queued behind slow writers 2026-10-30 15:32:33 +01:00
Mohamed Boudra 8c2a2a9a88 feat(transport): deliver large messages across bounded connections
Frame logical messages below serialization and routing so large history responses preserve their contents while fitting encrypted relay and host-channel limits. Negotiate support per connection and retain the existing logical size bound.
2026-10-30 15:28:41 +01:00
Mohamed Boudra a4f696620b Keep big agents loadable by bounding every timeline item (#8274)
* fix(server): bound every timeline item so one fetch cannot close the socket

A timeline page is one WebSocket message. Only three tool-call fields were
capped, so MCP results, file contents, errors and merged assistant rows could
make a 20-entry or even a 1-entry fetch pass the outbound high-water mark.

limitAgentTimelineItemContent is now one budgeted walk over any item: 64 KiB
for tool calls, 256 KiB for other items. Over budget, the largest strings are
cut to a shared length with a visible marker and oversized data URLs become a
marker. Fetches also apply it to merged assistant and reasoning rows.

* fix(server): keep short strings whole and typed arrays valid when limiting items

Strings up to 1 KiB are never cut, so a tool call keeps its callId and an
item limits to the same result twice. Arrays drop trailing elements
without inserting a marker element, which typed arrays such as todo items
would reject.
2026-10-30 15:27:27 +01:00
Mohamed Boudra 184ab66cde Show sub-agents as launch markers in the parent timeline (#8276)
* fix: make sub-agent cards launch markers

Sub-agent tool calls no longer accumulate the child's activity into
`log`. Claude, Codex, OpenCode, OMP and Pi emit the card when the child
starts and when its label or status changes; the child's messages and
tool calls stay in the sub-agent's own timeline. The app no longer
renders a sub-agent log.

The wire schema makes `log` optional. Daemons still send `log: ""` so
apps that require the field keep parsing the card.

* fix(server): put a tintinweb foreground answer in the child timeline

A foreground @tintinweb/pi-subagents child has no session file, so its
answer only exists in the Agent tool result. Emit that text as the
child's assistant message when the child finishes. Tag every sub_agent
producer's `log: ""` with COMPAT(subagent-log) so the shim removal
finds them.

* fix(server): settle child compactions and keep sub-agent cards stable

- Codex: a child compaction shown as loading now completes on the
  child's thread/compacted or turn end, once.
- Claude: a streamed child tool call's empty start input is a
  placeholder, so the assistant message's input reaches its card.
- OMP: the task card follows only its first child, so later children
  no longer re-send an unchanged card.

* fix(server): keep a gotgenes foreground answer on replay

A foreground @gotgenes/pi-subagents child finds its session file only
through the live runtime marker, which history does not keep. Without a
file, emit the spawn result text as the child's assistant message.
2026-10-30 21:26:38 +07:00
paseo-ai[bot] fbe24fcdb6 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-30 11:06:08 +00:00
Mohamed Boudra 787edb1bf9 Respect proxy variables throughout the daemon (#8177)
* fix(server): respect environment proxies across daemon traffic

Install proxy routing at daemon and plugin process startup, with a separate HTTP agent for outbound WebSockets. Always exempt loopback so local provider bridges remain reachable.

* test(server): run usage source regressions in CI

* fix(server): validate proxy settings without blocking startup

Normalize scheme-less proxies and share HTTP-only validation across fetch and WebSockets. Record ignored variables without credentials in the daemon log. Separate built-in and directory plugin regressions through a typed fixture.

* refactor(server): treat ignored proxy variables as unset

* fix(server): read Windows proxy environment entries once

* fix(server): apply proxy policy to connected host links

* test(server): isolate proxy fixture credential directories

* fix(server): keep proxy routing and authentication consistent

Normalize Windows environment names and wildcard bypass rules at the network boundary. Compare bypass ports numerically and keep Basic proxy credentials on the CONNECT pool, including empty credential fields.
2026-10-30 17:57:57 +07:00
Mohamed Boudra 385c29b655 Keep failed chat history available for retry (#8278)
* fix(app): keep failed chat history until retry

Automatic retries repeatedly reopen provider sessions after persistent failures. Keep the error visible until the user retries, while retaining reconnect recovery.

* fix(app): catch up chats after subscription failure

A failed replacement subscription leaves chats unsubscribed, so discard its old catch-up state and fetch missed history after retry.

* fix(app): recover gaps during history retry

Keep gap recovery active while a user-initiated retry is running so the chat is not marked current before its follow-up fetch.

* fix(app): show history errors in archived chats

Cached archived chats need the same manual retry affordance when history synchronization fails.
2026-10-30 17:57:52 +07:00
Mohamed Boudra 9477fe0c00 fix(app): refresh Android layout after background fold changes (#8285)
Backport the Unistyles 3.5.3 Activity configuration fix while keeping 3.2.4 and its existing fixes. Application resources could leave the responsive shell on the previous fold size after resume. Cover the real device-state sequence with a native UI regression.
2026-10-30 17:22:53 +07:00
531370c4ce fix(pi): show tool-result images live and after reload (#8280)
* pi: render tool-result images as inline image rows (paseo #1717 parity)

* fix(pi): preserve turn ownership while rendering images

* fix(pi): preserve metadata when stripping nested image payloads

---------

Co-authored-by: be-my-bot <bot@be-my-code.local>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: duyiliu <duyiliu@users.noreply.github.com>
2026-10-30 10:10:39 +00:00
paseo-bot[bot] e299b21e94 test(server): measure Git filter work within its callback (#8270)
Exclude unrelated callbacks and fixture garbage from the cold event-filter budget. Keep the 20ms limit and IO assertions, with unrelated CPU work as a regression control.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-30 09:17:50 +00:00
PeanutandPeanut-HZM 1f79efcd62 fix(providers): name the MiniMax Code binary so npx can resolve it (#8201)
The MiniMax Code catalog entry ran `npx -y @minimax-ai/code@0.6.13 acp`.
npx derives the executable from the unscoped package name, which is `code`,
but the package publishes `mcode` and `mcode-tools`. npm therefore exits
with "could not determine executable to run" before the agent starts.

Paseo reports this as a provider that timed out during `initialize`, so the
entry looks like a broken agent rather than a malformed command.

Name the package and the binary separately. The ACP handshake then completes
and the provider reports its models and modes.

Co-authored-by: Peanut-HZM <peanut@hzm.me>
2026-10-30 08:08:14 +00:00
Mohamed Boudra e3682ba18c Usage fetches only when shown, and can be turned off (#8181)
* fix(usage): fetch only when shown and add a host-wide off switch

* fix(usage): restore policy defaults and isolate usage test state

* refactor(usage): give the sidebar footer sole ownership of its summary
2026-10-30 14:32:19 +07:00
226 changed files with 7360 additions and 6195 deletions
+2 -2
View File
@@ -281,10 +281,10 @@ jobs:
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime.
server-tests-macos:
name: server-tests (macos-15, file observation)
name: server-tests (macos-14, file observation)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-15
runs-on: macos-14
steps:
- uses: actions/checkout@v4
+10
View File
@@ -493,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
## Outbound networking
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
are treated as unset, with one warning naming the variable in `daemon.log`.
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
`ALL_PROXY` configurations are not supported.
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
+1
View File
@@ -38,6 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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`).
- **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`).
- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear.
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
+14
View File
@@ -35,6 +35,20 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
For Android background fold recovery, install the release APK on a foldable AVD, open a
connected workspace, close the keyboard, and run:
```bash
ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
```
The AVD must expose `CLOSED` and `OPENED` device states. The test backgrounds the app,
changes posture, resumes immediately, and asserts compact/wide workspace controls without
restarting the process. It writes snapshots and native failure state under
`.dev/agent-device-artifacts/android-fold`. Set `PASEO_MOBILE_APP_ID=sh.paseo` for a
production package. This needs a native rebuild when the Unistyles patch changes; a Metro
reload cannot update it.
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
with an empty prompt, hide its keyboard, and run:
+1 -1
View File
@@ -550,7 +550,7 @@ string is never inline SVG or a URL.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Contribute sidebar items
+24 -11
View File
@@ -52,19 +52,32 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Terminal ANSI replay
## Binary chunking
Terminal replay intentionally drops JSON-era apps, an exception to the general cross-version policy:
use app v0.1.81 or newer. Earlier apps omit `restore` and reject ANSI Restore (`0x05`), so they show
only subsequent live output, without the existing screen or history.
`binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1`
negotiate connection-scoped framing. Application serializers and routed envelopes use
the same boundary. Small messages and handshakes keep their existing representation.
Older peers retain existing frame limits.
`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source
advertising the capability. The SDK keeps the established subscribe request against older
ANSI hosts and normalizes their single Restore frame into begin/data/end. `observeTerminal` defaults
to an explicit full snapshot: omitting restore on an ANSI-era host selects its discarded JSON path.
Explicit caller restore modes and history counts pass through unchanged. JSON snapshot
wire declarations remain accepted, but JSON snapshot frames have no rendering runtime.
Terminals require `terminal-restore-modes`; older hosts show an update message without attaching.
The reserved binary opcode `0x31` is followed by version `1`, original kind
(`0` text, `1` binary), and START/END flag bits `0`/`1`. Remaining bytes are an
unchanged slice. There is no total length or chunk count; a future receiver can
consume slices incrementally. Today the receiver buffers up to the existing
100 MiB logical limit and decodes UTF-8 only after END. Transfers cannot interleave.
Disconnect clears partial state and negotiation.
The lower transport supplies its plaintext frame budget, including encryption
overhead. The sender waits for drain between chunks while ordinary messages keep
the existing delivery path. Slow-client protections count messages queued behind
an active transfer as well as bytes already in the socket.
The framing module never selects an encryption construction.
Native uploads retain the platform's existing queue limits. React Native's
WebSocket does not report its queued bytes, so the client cannot wait for native
drain. Android's installed OkHttp transport closes when a send would put more
than 16 MiB in its queue. The native transport disables outbound chunking, including
on routed channels, and retains its existing single-frame admission errors.
Inbound chunking remains enabled; daemon-to-native responses use the daemon's paced writes.
## Owned observations
+6 -10
View File
@@ -6,9 +6,9 @@ How terminal output stays low-latency, what the invariants are, and how to measu
```
pty (node-pty, forked worker process)
→ headless xterm parse (worker, ANSI replay fidelity)
→ TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
→ framed local socket → daemon main process
→ headless xterm parse (worker, snapshot fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
→ process.send IPC → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime
@@ -19,15 +19,11 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
## Invariants (the easy-to-break ones)
- **Replay is incremental across the daemon and SDK.** Capable clients request their configured history, bounded by [worker retention](terminal-worker.md#process-model). Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
- **Replay serialization reads xterm directly.** The [worker](terminal-worker.md#process-model) uses its existing buffer; text capture translates only the requested rows, and readiness scans row strings. Do not rebuild a full cell-object grid. Serialization still allocates a whole ANSI string and UTF-8 bytes before bounded delivery; chunking does not bound those allocations.
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Replay delivery is isolated per viewer.** Live worker output never waits for app delivery. Each daemon stream owns its bounded live tail and revision deduplication; a slow or failed replay cannot pause another viewer of the same terminal.
- **Replay geometry belongs to the parser queue.** Native measurements and web fits must leave the captured dimensions intact through every chunk's commit. Apply the latest measured size after replay, before live bytes; clear, replacement and disposal must release that geometry ownership. A layout effect must not resize the renderer directly during replay.
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
- **The input-mode tracker runs once per process boundary, not per hop.** The worker commits its authoritative tracker with xterm parsing before publishing the corresponding revision; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** After more than 256 KiB of output, a stream catches up only when its transport reports more than 4 MiB queued, or exposes no backpressure reading. A draining client streams continuously. Pressure recovery sends at most 500 recent history rows plus the screen, preserving smaller explicit demands including zero; even a live-only subscription needs this resynchronization. It does not change the subscription's configured demand: later attach/reconnect restores still request the full configured history. The recent window avoids repeatedly serializing all retained history for a slow viewer.
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
+1 -15
View File
@@ -15,8 +15,6 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
- 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.
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
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
@@ -42,22 +40,10 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
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. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
**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.
## Display replay
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
ConPTY renders a console rather than forwarding every program escape sequence unchanged. A program write callback does not establish that its hidden cursor move or input modes reached the worker. Test exact ANSI fidelity at the serializer boundary; PTY integration compares the initial replay plus committed observed output at the capture cut, including startup modes.
The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running.
Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication.
The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
## 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:
+10 -4
View File
@@ -21,10 +21,16 @@ The invariants are:
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
tool payload.
Every item is bounded before it enters either delivery path, because a timeline page is one
WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get
64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a
`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. Strings up
to 1 KiB stay whole, so ids and paths survive. Arrays drop their trailing elements without a marker
element, so typed arrays still match the wire schema. Limiting an item twice gives the same item. The
budget does not bound wide objects or multi-byte text. The same
bounded item is used for runtime timeline rows and live stream events. Provider history hydration
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
again to assistant and reasoning rows the projection merges from many chunks.
## Presence is not delivery
+1 -1
View File
@@ -1 +1 @@
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
sha256-8NOrrCAxraQ74lP3Vy59MRhloBfzStTc4rW+ltc42Fg=
+24 -8
View File
@@ -14740,12 +14740,6 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-serialize": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
"license": "MIT"
},
"node_modules/@xterm/headless": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
@@ -37709,7 +37703,6 @@
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
@@ -40689,7 +40682,6 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -40698,6 +40690,7 @@
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
@@ -40715,6 +40708,7 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -41288,6 +41282,15 @@
"url": "https://opencollective.com/vitest"
}
},
"packages/server/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -41357,6 +41360,19 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
@@ -18,7 +18,7 @@ function visibleMainPane(page: Page) {
}
function composerChangesPill(page: Page) {
return page.getByTestId("composer-diff-stat-pill");
return page.getByRole("button", { name: "Open Changes tab", exact: true });
}
async function revealComposerChangesInExplorer(page: Page) {
@@ -498,6 +498,7 @@ test.describe("send review feedback", () => {
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
page,
}) => {
test.setTimeout(120000);
const workspace = await seedChangedAgent("review-feedback-multiple-");
const other = await seedChangedAgent("review-feedback-other-");
try {
@@ -1,9 +1,8 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
openDaemonOverview,
@@ -14,10 +13,6 @@ import {
readWorkerPid,
openLongLivedTerminal,
listTerminalIds,
suspendDaemonProcess,
resumeDaemonProcess,
daemonProcessCanWrite,
readTerminalProcessIds,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -124,51 +119,3 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
);
return paseoHome;
}
// Cleanup has no renderer demand: drive the same isolated daemon owner directly.
// A stopped worker deterministically outlives the old helper's five-second
// supervisor-only kill and logs into its deleted home when resumed.
cleanupTest(
"isolated cleanup prevents a stopped worker from recreating its deleted home",
async () => {
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
const daemon = await startIsolatedHostDaemon(randomUUID());
const workerPid = await readWorkerPid(daemon);
try {
suspendDaemonProcess(workerPid);
await daemon.close();
resumeDaemonProcess(workerPid);
await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false);
expect(existsSync(daemon.paseoHome)).toBe(false);
} finally {
resumeDaemonProcess(workerPid);
await daemon.close();
await rm(daemon.paseoHome, { recursive: true, force: true });
}
},
);
cleanupTest(
"isolated cleanup force-stops a frozen supervisor and its detached terminal writers",
async () => {
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
const daemon = await startIsolatedHostDaemon(randomUUID());
const supervisorPid = daemon.getPid()!;
const workerPid = await readWorkerPid(daemon);
try {
await openLongLivedTerminal(daemon);
const terminalProcesses = await readTerminalProcessIds(daemon);
expect(terminalProcesses.length).toBeGreaterThanOrEqual(2);
suspendDaemonProcess(supervisorPid);
await daemon.close();
const writerStates = await Promise.all(
[supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite),
);
expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false));
expect(existsSync(daemon.paseoHome)).toBe(false);
} finally {
resumeDaemonProcess(supervisorPid);
await daemon.close();
}
},
);
@@ -1,3 +1,4 @@
import { chooseModel, startWithoutRememberedModel } from "../support/helpers/model-memory";
import {
composerLocator,
enterComposerFullscreen,
@@ -97,6 +98,7 @@ test.describe("New workspace composer draft", () => {
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
try {
await startWithoutRememberedModel(page);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
@@ -104,6 +106,7 @@ test.describe("New workspace composer draft", () => {
projectDisplayName: project.projectDisplayName,
});
await chooseModel(page, "mock", "Ten second stream");
const composer = page.getByRole("textbox", { name: "Message agent..." });
const createButton = page.getByTestId("workspace-create-submit");
await expect(composer).toBeEditable({ timeout: 30_000 });
@@ -1,10 +1,5 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import {
expectTerminalCursor,
getTerminalBufferText,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window {
@@ -64,65 +59,4 @@ test.describe("retained terminal tab streams", () => {
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
});
for (const { count, retainedMarker } of [
{ count: 3000, retainedMarker: "history-0001界🙂" },
{ count: 12050, retainedMarker: "history-3000界🙂" },
]) {
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
page,
}) => {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 }));
});
const terminal = await harness.createTerminal({
name: `history-${count}`,
command: "/bin/sh",
args: [
"-lc",
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`,
],
});
const last = `history-${String(count).padStart(4, "0")}界🙂`;
await harness.openTerminal(page, { terminalId: terminal.id });
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
await page.reload();
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
const restored = await getTerminalBufferText(page);
expect(restored).toContain(retainedMarker);
expect(restored.match(/history-\d{4,5}/g)?.length).toBeGreaterThanOrEqual(
Math.min(count, 10000),
);
if (count > 10000) expect(restored).not.toContain("history-0001界🙂");
});
}
for (const input of [
{ name: "client blinking bar", presentation: "", shape: "bar" as const, blinking: true },
{
name: "program blinking underline",
presentation: "\x1b[3 q",
shape: "underline" as const,
blinking: true,
},
]) {
test(`reattach preserves the ${input.name} cursor`, async ({ page }) => {
const terminal = await harness.createTerminal({
name: "cursor-presentation",
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`,
],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
await expectTerminalCursor(page, input);
await page.reload();
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
await expectTerminalCursor(page, input);
});
}
});
@@ -0,0 +1,69 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
openUsageFromIcon,
openUsageOptions,
closeUsageOptions,
closeUsage,
expectNoUsageItem,
expectPinnedUsage,
} from "../support/helpers/usage-sidebar-item";
test("Check usage applies to the host and Turn on restores cards", async ({
page,
e2eWorkerClient,
}) => {
test.setTimeout(180_000);
const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true };
try {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
await openUsageOptions(page);
await page.getByRole("switch", { name: "Check usage", exact: true }).click();
await closeUsageOptions(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0);
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false });
await expectNoUsageItem(page);
const before = usage.listRequests().length;
await closeUsage(page);
await page.reload();
await openUsageFromIcon(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
expect(usage.listRequests()).toHaveLength(before);
await page.getByRole("button", { name: "Turn on", exact: true }).click();
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true });
// A separate client changes the policy, as another device would.
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } });
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } });
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
} finally {
await e2eWorkerClient.patchDaemonConfig({ usage: previous });
}
});
test("older hosts keep usage without a Check usage switch", async ({ page }) => {
test.setTimeout(180_000);
await installUsageReportsFixture(page, {
usageSettingSupported: false,
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
await openUsageOptions(page);
await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0);
});
@@ -441,3 +441,31 @@ test("released hosts supply source logos through the client conversion", async (
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-modal");
});
test("hidden usage sends no requests until opened", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectNoUsageItem(page);
await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({
timeout: 90_000,
});
await openSidebarNavSettings(page);
await expectFooterItemSetting(page, "usage", false);
await leaveSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary requests reports on load", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
+104
View File
@@ -0,0 +1,104 @@
// Run a release build with a connected workspace open and the keyboard hidden.
// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const serial = process.env.ANDROID_SERIAL;
assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator");
const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug";
const session = "android-fold";
const artifacts = resolve(".dev/agent-device-artifacts/android-fold");
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"),
};
mkdirSync(artifacts, { recursive: true });
function run(binary, args) {
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 });
}
function adb(...args) {
return run("adb", ["-s", serial, ...args]);
}
function ad(...args) {
return run("agent-device", [...args, "--session", session]);
}
function pid() {
return adb("shell", "pidof", appId).trim();
}
function open() {
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
}
function stateId(name) {
const states = adb("shell", "cmd", "device_state", "print-states");
const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`));
assert(match, `Emulator must expose the ${name} posture: ${states}`);
return match[1];
}
async function expectLayout(posture) {
const compact = posture === "CLOSED";
const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll";
const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger";
const deadline = Date.now() + 15_000;
let nodes = [];
do {
let data;
try {
({ data } = JSON.parse(ad("snapshot", "-i", "--json")));
} catch (error) {
// During display handoff Android briefly has no capturable app window.
if (!error.stdout?.includes("insufficient foreground app content")) throw error;
await delay(200);
continue;
}
assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo");
nodes = data.nodes;
if (
nodes.some((node) => node.identifier === expectedId) &&
!nodes.some((node) => node.identifier === unexpectedId)
) {
writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2));
console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`);
return;
}
await delay(200);
} while (Date.now() < deadline);
writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2));
writeFileSync(
resolve(artifacts, "native-failure.txt"),
adb("shell", "dumpsys", "activity", "top"),
);
assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`);
}
const closed = stateId("CLOSED");
const opened = stateId("OPENED");
const initialState = adb("shell", "cmd", "device_state", "state").match(
/Committed state: DeviceState\{identifier=(\d+)/,
)?.[1];
assert(initialState, "Could not read the initial fold state");
try {
adb("shell", "cmd", "device_state", "state", opened);
open();
await expectLayout("OPENED");
const initialPid = pid();
for (const [posture, id] of [
["CLOSED", closed],
["OPENED", opened],
]) {
adb("shell", "input", "keyevent", "KEYCODE_HOME");
adb("shell", "cmd", "device_state", "state", id);
// Resume immediately: waiting for configuration delivery hides the regression.
open();
await expectLayout(posture);
assert.equal(pid(), initialPid, "Fold/resume must preserve the app process");
}
} finally {
adb("shell", "cmd", "device_state", "state", initialState);
ad("close");
}
@@ -1,5 +1,5 @@
import { execFile, spawn } from "node:child_process";
import { writeFile, readFile, readdir } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test";
@@ -154,42 +154,3 @@ export async function plantUnclosableTerminalProgram(
throw error;
}
}
/** Fault injection and liveness checks for the isolated daemon cleanup journey. */
export function suspendDaemonProcess(pid: number): void {
process.kill(pid, "SIGSTOP");
}
export function resumeDaemonProcess(pid: number): void {
try {
process.kill(pid, "SIGCONT");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}
export async function daemonProcessCanWrite(pid: number): Promise<boolean> {
try {
const state = await psField(pid, "stat");
return state.length > 0 && !state.startsWith("Z");
} catch (error) {
if ((error as { code?: unknown }).code === 1) return false;
throw error;
}
}
export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise<number[]> {
const directory = path.join(daemon.paseoHome, "terminal-worker");
const records = await Promise.all(
(await readdir(directory))
.filter((name) => name.endsWith(".json"))
.map(async (name) => {
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ pid: number }>;
};
return [record.pid].concat(record.terminals.map((terminal) => terminal.pid));
}),
);
return records.flat();
}
@@ -1,11 +1,4 @@
import { promisify } from "node:util";
import {
spawn,
execFile,
execFileSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
@@ -13,8 +6,6 @@ import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
const execFileAsync = promisify(execFile);
export interface IsolatedHostDaemon {
serverId: string;
port: number;
@@ -161,24 +152,6 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server");
let child: ChildProcess | null = null;
const stopDaemon = async (): Promise<void> => {
// The CLI owns graceful shutdown, forced cleanup and detached terminal
// reaping. Keep its ESM graph out of Playwright's transforming loader.
await execFileAsync(
process.execPath,
[
path.resolve(__dirname, "../../../../cli/dist/index.js"),
"daemon",
"stop",
"--home",
paseoHome,
"--force",
],
{ timeout: 30_000 },
);
await killProcessTree(child);
};
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
@@ -196,21 +169,21 @@ export async function startIsolatedHostDaemon(
stdio: ["ignore", "ignore", "pipe"],
detached: false,
};
const spawned = publishedPackageRoot
const child = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
child = spawned;
let stderr = "";
spawned.stderr?.on("data", (chunk: Buffer) => {
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
try {
await waitForServer(port, spawned);
return spawned;
await waitForServer(port, child);
return child;
} catch (error) {
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -218,10 +191,10 @@ export async function startIsolatedHostDaemon(
}
};
let child: ChildProcess;
try {
await spawnDaemon();
child = await spawnDaemon();
} catch (error) {
await stopDaemon();
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
@@ -236,22 +209,22 @@ export async function startIsolatedHostDaemon(
serverId,
port,
paseoHome,
getPid: () => child?.pid,
getPid: () => child.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopDaemon();
await spawnDaemon();
await killProcessTree(child);
child = await spawnDaemon();
},
close: async () => {
if (closed) return;
await stopDaemon();
closed = true;
await killProcessTree(child);
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
if (publishedPackageRoot) {
await rm(publishedPackageRoot, { recursive: true, force: true });
}
closed = true;
},
};
}
@@ -28,6 +28,10 @@ interface SeedProjectDescriptor {
* prefer those wrappers over reaching for this client directly.
*/
export interface SeedDaemonClient {
patchDaemonConfig(
config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch,
): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
connect(): Promise<void>;
close(): Promise<void>;
addProject(cwd: string): Promise<{
@@ -1,5 +1,4 @@
import { expect, type Page } from "@playwright/test";
import type { Terminal } from "@xterm/xterm";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { getServerId } from "./server-id";
@@ -207,100 +206,3 @@ export function computePercentile(samples: number[], p: number): number {
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/** Observe actual cursor pixels and blink phases through xterm's public render event. */
export async function expectTerminalCursor(
page: Page,
expected: { shape: "bar" | "underline"; blinking: boolean },
): Promise<void> {
const observed = await page.evaluate(async () => {
const terminal = (window as Window & { __paseoTerminal?: Terminal }).__paseoTerminal;
if (!terminal) throw new Error("Terminal did not mount");
const source = Array.from(document.querySelectorAll(".xterm-screen canvas")).find(
(canvas): canvas is HTMLCanvasElement =>
canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2") !== null,
);
if (!source) throw new Error("Cursor verification requires the actual WebGL renderer");
const copy = document.createElement("canvas");
const context = copy.getContext("2d");
if (!context) throw new Error("Cursor pixel capture is unavailable");
terminal.focus();
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
if (!terminal.element?.contains(document.activeElement))
throw new Error("Terminal did not focus");
const started = performance.now();
const frames: Array<{ time: number; width: number; height: number }> = [];
const rendered = terminal.onRender(() => {
copy.width = source.width;
copy.height = source.height;
// WebGL's default drawing buffer must be copied before this render returns.
context.drawImage(source, 0, 0);
const cellWidth = source.width / terminal.cols;
const cellHeight = source.height / terminal.rows;
const x = Math.round(terminal.buffer.active.cursorX * cellWidth);
const y = Math.round(terminal.buffer.active.cursorY * cellHeight);
const width = Math.ceil(cellWidth);
const height = Math.ceil(cellHeight);
const background = context.getImageData(x + width + 2, y, 1, 1).data;
const pixels = context.getImageData(x, y, width, height).data;
let minX = width;
let maxX = -1;
let minY = height;
let maxY = -1;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const offset = (row * width + col) * 4;
if (
![0, 1, 2, 3].some(
(channel) => Math.abs(pixels[offset + channel]! - background[channel]!) > 20,
)
)
continue;
minX = Math.min(minX, col);
maxX = Math.max(maxX, col);
minY = Math.min(minY, row);
maxY = Math.max(maxY, row);
}
}
frames.push({
time: performance.now() - started,
width: maxX < 0 ? 0 : (maxX - minX + 1) / cellWidth,
height: maxY < 0 ? 0 : (maxY - minY + 1) / cellHeight,
});
});
try {
const refresh = setInterval(() => terminal.refresh(0, terminal.rows - 1), 40);
try {
await new Promise((resolve) => setTimeout(resolve, 1800));
} finally {
clearInterval(refresh);
}
} finally {
rendered.dispose();
}
return {
frames,
focused: terminal.element?.contains(document.activeElement),
samples: frames.length,
width: Math.max(...frames.map((frame) => frame.width)),
height: Math.max(...frames.map((frame) => frame.height)),
blinking:
frames.filter(
(frame, index) => index > 0 && (frame.width === 0) !== (frames[index - 1]!.width === 0),
).length >= 2,
};
});
expect(observed.samples).toBeGreaterThan(5);
expect(observed.blinking, JSON.stringify(observed)).toBe(expected.blinking);
if (expected.shape === "bar") {
expect(observed.width).toBeGreaterThan(0);
expect(observed.width).toBeLessThan(0.4);
expect(observed.height).toBeGreaterThan(0.6);
} else {
expect(observed.width).toBeGreaterThan(0.6);
expect(observed.height).toBeGreaterThan(0);
expect(observed.height).toBeLessThan(0.4);
}
}
@@ -25,6 +25,7 @@ interface UsageReportsFixtureOptions {
* app connects, so a reload can switch it.
*/
usageSupported?: boolean | (() => boolean);
usageSettingSupported?: boolean;
/** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */
@@ -63,6 +64,7 @@ function withUsageSupportFeature(
message: WebSocketMessage,
enabled: boolean,
providerUsageListOnly: boolean,
usageSettingSupported: boolean,
): string | null {
const envelope = parseJson(message) as {
type?: unknown;
@@ -86,6 +88,7 @@ function withUsageSupportFeature(
...payload,
features: {
...features,
usageSetting: usageSettingSupported ? true : undefined,
usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled,
},
@@ -229,7 +232,12 @@ export async function installUsageReportsFixture(
server.onMessage((message) => {
const serverInfo =
typeof message === "string"
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
? withUsageSupportFeature(
message,
isUsageSupported(),
providerUsageListOnly,
options.usageSettingSupported ?? true,
)
: null;
ws.send(serverInfo ?? message);
});
-1
View File
@@ -154,7 +154,6 @@
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
+4 -9
View File
@@ -64,12 +64,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "@/usage";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -480,10 +475,10 @@ function SidebarUsageIcon({ label }: { label: string }) {
*/
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer");
const hasUsageSummary = useHasUsageSummary();
const usageSummary = useUsageSummary();
const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter(
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
(item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0),
);
if (visibleItems.length === 0) return null;
return (
@@ -499,7 +494,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} />
<UsageSidebarItem key={item.key} sources={usageSummary} />
),
)}
</View>
@@ -4,6 +4,7 @@ import type {
} from "../terminal/runtime/terminal-emulator-runtime";
import type { Ref } from "react";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
@@ -18,7 +19,8 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
@@ -42,6 +44,7 @@ export interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
@@ -13,6 +13,7 @@ import {
type ViewStyle,
} from "react-native";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
TerminalInputModeTracker,
terminalInputModeStatesEqual,
@@ -119,8 +120,43 @@ function resolvePanResponderPoint(
};
}
function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize {
return {
rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS,
cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS,
};
}
function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState {
const visibleRows = viewport.grid.length;
const currentViewport = {
firstRow: viewport.firstRow,
lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1),
};
return {
viewport,
scroll: {
mode: "following",
firstRow: viewport.firstRow,
visibleRows,
oldestRow: viewport.oldestRow,
newestRow: viewport.newestRow,
currentViewport,
bottomViewport: currentViewport,
},
};
}
function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState {
return {
rows: state.grid.length,
cols: state.cols,
firstRow: 0,
oldestRow: 0,
newestRow: Math.max(0, state.grid.length - 1),
grid: state.grid,
cursor: state.cursor,
};
}
function NativeTerminalEmulator({
@@ -133,6 +169,7 @@ function NativeTerminalEmulator({
fontSize,
keyboardInset = 0,
isKeyboardVisible = false,
initialSnapshot = null,
onInput,
onTerminalKey,
onResize,
@@ -144,18 +181,20 @@ function NativeTerminalEmulator({
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const terminalRef = useRef<NativeHeadlessTerminal | null>(null);
const mountInputRef = useRef({ streamKey });
const mountInputRef = useRef({ streamKey, initialSnapshot });
if (
resolveNativeTerminalMountAction({
mountedStreamKey: mountInputRef.current.streamKey,
nextStreamKey: streamKey,
}) === "mount"
) {
mountInputRef.current = { streamKey };
mountInputRef.current = { streamKey, initialSnapshot };
}
const inputRef = useRef<TerminalInputHandle>(null);
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize());
const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize()));
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize(initialSnapshot));
const resizePolicyRef = useRef(
createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)),
);
const activeResizeClaimTokenRef = useRef(0);
const layoutRef = useRef<TerminalMeasuredLayout | null>(null);
const metricsRef = useRef<TerminalGridCellMetrics | null>(null);
@@ -181,7 +220,11 @@ function NativeTerminalEmulator({
...inputModeTrackerRef.current.getState(),
applicationCursorKeys: false,
});
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(null);
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(() => {
return initialSnapshot
? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot))
: null;
});
const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null);
const selectionRangeRef = useRef<TerminalSelectionRange | null>(null);
const hasNotifiedSelectionRef = useRef(false);
@@ -318,14 +361,10 @@ function NativeTerminalEmulator({
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
createNativeTerminalOutputDrain({
write: async (chunk) => {
inputModeTrackerRef.current.feed(chunk);
await terminal.write(chunk);
emitInputModeChange();
},
restoreSize: () => terminal.resize(terminalSizeRef.current),
reset: (size) => {
inputModeTrackerRef.current.reset();
if (size) terminal.resize(size);
reset: () => {
terminal.reset();
emitInputModeChange();
},
@@ -379,7 +418,8 @@ function NativeTerminalEmulator({
hasNotifiedMeasuredSizeRef.current = true;
terminalSizeRef.current = policyResult.measuredSize;
if (policyResult.measuredSizeChanged) {
outputDrainRef.current?.resize();
terminalRef.current?.resize(policyResult.measuredSize);
schedulePaint();
}
callbacksRef.current.onResize?.({
rows: policyResult.measuredSize.rows,
@@ -388,51 +428,68 @@ function NativeTerminalEmulator({
forceClaim: policyResult.resizeClaim?.force,
});
},
[],
[schedulePaint],
);
const resetTerminal = useCallback(() => {
outputDrainRef.current?.dispose();
outputDrainRef.current = null;
terminalRef.current?.dispose();
screenModelRef.current = null;
latestResolvedScreenRef.current = null;
clearSelection();
const measuredSize = resolveMeasuredNativeTerminalSize({
layout: layoutRef.current,
metrics: metricsRef.current,
});
const size = measuredSize ?? terminalSizeRef.current;
terminalSizeRef.current = size;
resizePolicyRef.current = createTerminalResizePolicy(size);
outputDecoderRef.current.decode();
const terminal = createNativeHeadlessTerminal({
rows: size.rows,
cols: size.cols,
const resetTerminal = useCallback(
(snapshot: TerminalState | null) => {
outputDrainRef.current?.dispose();
outputDrainRef.current = null;
terminalRef.current?.dispose();
screenModelRef.current = null;
latestResolvedScreenRef.current = null;
clearSelection();
const measuredSize = resolveMeasuredNativeTerminalSize({
layout: layoutRef.current,
metrics: metricsRef.current,
});
const size =
measuredSize ??
(snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current);
terminalSizeRef.current = size;
resizePolicyRef.current = createTerminalResizePolicy(size);
outputDecoderRef.current.decode();
const terminal = createNativeHeadlessTerminal({
rows: size.rows,
cols: size.cols,
scrollbackLines,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
const outputDrain = createOutputDrain(terminal);
outputDrainRef.current = outputDrain;
const screen = snapshot
? screenStateFromViewport(viewportStateFromSnapshot(snapshot))
: resolveTerminalScreen(terminal);
commitTerminalScreen(screen);
resetInputModeTracker();
if (snapshot) {
outputDrain.restoreSnapshot(snapshot);
}
},
[
commitTerminalScreen,
createOutputDrain,
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
const outputDrain = createOutputDrain(terminal);
outputDrainRef.current = outputDrain;
const screen = resolveTerminalScreen(terminal);
commitTerminalScreen(screen);
resetInputModeTracker();
}, [
commitTerminalScreen,
createOutputDrain,
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
clearSelection,
]);
clearSelection,
],
);
const enqueueOutputText = useCallback((text: string) => {
if (text.length === 0) {
return;
}
outputDrainRef.current?.enqueueText(text);
}, []);
const enqueueOutputText = useCallback(
(text: string) => {
if (text.length === 0) {
return;
}
const inputModeUpdate = inputModeTrackerRef.current.feed(text);
if (inputModeUpdate.changed) {
emitInputModeChange();
}
outputDrainRef.current?.enqueueText(text);
},
[emitInputModeChange],
);
const claimActiveTerminalSize = useCallback(() => {
activeResizeClaimTokenRef.current += 1;
@@ -455,7 +512,27 @@ function NativeTerminalEmulator({
const text = outputDecoderRef.current.decode(data, { stream: true });
enqueueOutputText(text);
},
replay: (event) => outputDrainRef.current?.replay(event),
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
const text = outputDecoderRef.current.decode(data, { stream: false });
outputDrainRef.current?.restoreText(text);
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
if (!state) {
resetTerminal(null);
return;
}
clearSelection();
const outputDrain = outputDrainRef.current;
if (!outputDrain) {
resetTerminal(state);
return;
}
outputDrain.restoreSnapshot(state);
},
paste: (text: string) => {
if (text.length === 0) {
return;
@@ -486,6 +563,7 @@ function NativeTerminalEmulator({
screenModelRef.current?.reset();
clearSelection();
resetInputModeTracker();
terminalRef.current?.reset();
schedulePaint();
},
claimSize: claimActiveTerminalSize,
@@ -504,13 +582,14 @@ function NativeTerminalEmulator({
claimActiveTerminalSizeForAction,
clearSelection,
resetInputModeTracker,
resetTerminal,
schedulePaint,
],
);
useEffect(() => {
hasNotifiedMeasuredSizeRef.current = false;
resetTerminal();
resetTerminal(mountInputRef.current.initialSnapshot);
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true });
return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
@@ -19,6 +19,7 @@ import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import {
@@ -39,7 +40,8 @@ import { focusWithRetries } from "../utils/web-focus";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
@@ -106,6 +108,7 @@ interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
@@ -144,6 +147,16 @@ declare global {
interface Window {}
}
function isTerminalState(value: unknown): value is TerminalState {
return (
typeof value === "object" &&
value !== null &&
"rows" in value &&
"cols" in value &&
"grid" in value
);
}
export default function TerminalEmulator({
ref,
streamKey,
@@ -159,6 +172,7 @@ export default function TerminalEmulator({
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onFindRequest,
onFindResult,
onInput,
@@ -214,6 +228,8 @@ export default function TerminalEmulator({
onOpenLocalFileLink,
onFilePaste,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
@@ -237,17 +253,17 @@ export default function TerminalEmulator({
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
replay: (event) => {
if (!event || typeof event !== "object" || Array.isArray(event)) return;
if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
if (event.type === "data" && event.data instanceof Uint8Array)
runtimeRef.current?.replay({ type: "data", data: event.data });
if (event.type === "begin")
runtimeRef.current?.replay({
type: "begin",
rows: typeof event.rows === "number" ? event.rows : undefined,
cols: typeof event.cols === "number" ? event.cols : undefined,
});
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (...args) => {
const state = args[0];
if (state === null) {
runtimeRef.current?.renderSnapshot({ state: null });
} else if (isTerminalState(state)) {
runtimeRef.current?.renderSnapshot({ state });
}
},
paste: (...args) => {
const text = args[0];
@@ -283,7 +299,12 @@ export default function TerminalEmulator({
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
replay: (event) => runtimeRef.current?.replay(event),
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
paste: (text: string) => {
pasteText(text);
},
@@ -450,6 +471,7 @@ export default function TerminalEmulator({
runtime.mount({
root,
host,
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current,
fontFamily: fontFamilyRef.current,
+43 -5
View File
@@ -15,6 +15,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState,
@@ -59,6 +60,7 @@ import { getDesktopHost } from "@/desktop/host";
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
@@ -86,6 +88,7 @@ import { isNative } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
@@ -313,6 +316,10 @@ export function TerminalPane({
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() => getWorkspaceTerminalSession({ scopeKey }),
[scopeKey],
);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
@@ -376,6 +383,7 @@ export function TerminalPane({
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -433,8 +441,16 @@ export function TerminalPane({
(change: TerminalRendererReadyChange) => {
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
return;
}
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
if (snapshot) {
emulatorRef.current?.renderSnapshot(snapshot);
}
},
[terminalStreamKey],
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
);
useEffect(() => {
@@ -548,6 +564,7 @@ export function TerminalPane({
);
const handleStreamExit = useStableEvent((exitedTerminalId: string) => {
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear();
setModifiers({ ...EMPTY_MODIFIERS });
});
@@ -587,10 +604,29 @@ export function TerminalPane({
},
);
const handleStreamRestore = useStableEvent(
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
);
const handleStreamSnapshot = useStableEvent(
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
if (terminalIdRef.current !== snapshotTerminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
},
);
const getStreamRestoreOptions = useStableEvent(() =>
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
scrollbackLines: settings.terminalScrollbackLines,
canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalPresentedRef.current,
isPaneFocused,
@@ -617,9 +653,8 @@ export function TerminalPane({
client,
getPreferredSize: getPreferredStreamSize,
onOutput: handleStreamOutput,
onReplay: ({ terminalId: id, replay }) => {
if (terminalIdRef.current === id) emulatorRef.current?.replay(replay);
},
onRestore: handleStreamRestore,
onSnapshot: handleStreamSnapshot,
onExit: handleStreamExit,
getRestoreOptions: getStreamRestoreOptions,
onStatusChange: handleStreamControllerStatus,
@@ -639,6 +674,8 @@ export function TerminalPane({
getStreamRestoreOptions,
handleStreamControllerStatus,
handleStreamOutput,
handleStreamRestore,
handleStreamSnapshot,
handleStreamExit,
isConnected,
]);
@@ -1179,6 +1216,7 @@ export function TerminalPane({
keyboardInset={keyboardInset}
isKeyboardVisible={isKeyboardVisible}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
@@ -64,7 +64,7 @@ function resolveShouldFill(
): boolean {
if (!fillAvailableHeight) return false;
const t = detail?.type;
return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
return t === "shell" || t === "edit" || t === "write" || t === "read";
}
function useDetailStyles(
@@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
);
}
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
fallbackText: string,
): string {
if (subAgentType && description) {
return `${subAgentType}: ${description}`;
}
return subAgentType ?? description ?? fallbackText;
}
interface SubAgentDetailProps {
log: string;
childSessionId: string | null | undefined;
subAgentType: string | null | undefined;
description: string | null | undefined;
ds: DetailStyles;
}
interface SubAgentActivityRow {
index: number;
toolName: string;
summary?: string;
}
interface ParsedSubAgentLog {
actions: SubAgentActivityRow[];
remainingLog: string;
}
function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null {
const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/);
if (!match) {
return null;
}
const toolName = match[1]?.trim();
if (!toolName) {
return null;
}
const summary = match[2]?.trim();
return {
index,
toolName,
...(summary ? { summary } : {}),
};
}
function parseSubAgentLog(log: string): ParsedSubAgentLog {
const actions: SubAgentActivityRow[] = [];
const remainingLines: string[] = [];
for (const line of log.replace(/^\n+/, "").split("\n")) {
const normalizedLine = line.trim();
if (!normalizedLine) {
continue;
}
const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1);
if (parsedAction) {
actions.push(parsedAction);
} else {
remainingLines.push(line);
}
}
return {
actions,
remainingLog: remainingLines.join("\n").replace(/^\n+/, ""),
};
}
function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) {
return (
<View style={styles.subAgentActionRow}>
<Text selectable style={styles.subAgentActionTool}>
{formatSubAgentToolName(action.toolName)}
</Text>
{action.summary ? (
<Text selectable style={styles.subAgentActionSummary}>
{action.summary}
</Text>
) : null}
</View>
);
}
function formatSubAgentToolName(toolName: string): string {
const trimmed = toolName.trim();
if (!trimmed) {
return toolName;
}
return trimmed
.replace(/[._-]+/g, " ")
.split(" ")
.filter((segment) => segment.length > 0)
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
.join(" ");
}
function SubAgentLogText({
activityLog,
fallbackHeader,
hasActions,
}: {
activityLog: string;
fallbackHeader: string;
hasActions: boolean;
}) {
if (activityLog.length > 0) {
return (
<Text selectable style={styles.scrollText}>
{activityLog}
</Text>
);
}
if (!hasActions) {
return (
<Text selectable style={styles.scrollText}>
{fallbackHeader}
</Text>
);
}
return null;
}
function SubAgentDetailSection({
log,
childSessionId,
subAgentType,
description,
ds,
}: SubAgentDetailProps) {
const { t } = useTranslation();
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
const fallbackHeader = resolveSubAgentFallbackHeader(
subAgentType,
description,
t("toolCallDetails.subAgentActivity"),
);
const hasActions = actions.length > 0;
return (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
<ScrollView
style={ds.codeVerticalScrollStyle}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
{childSessionId ? (
<Text selectable style={styles.subAgentSessionText}>
session {childSessionId}
</Text>
) : null}
{hasActions ? (
<View style={styles.subAgentActions}>
{actions.map((action) => (
<SubAgentActionRow key={action.index} action={action} />
))}
</View>
) : null}
<SubAgentLogText
activityLog={remainingLog}
fallbackHeader={fallbackHeader}
hasActions={hasActions}
/>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
}
interface EditDetailProps {
diffLines: DiffLine[] | undefined;
ds: DetailStyles;
@@ -636,19 +458,7 @@ function buildDetailSections(
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
];
}
if (detail.type === "worktree_setup") return [];
if (detail.type === "sub_agent") {
return [
<SubAgentDetailSection
key="sub-agent"
log={detail.log}
childSessionId={detail.childSessionId}
subAgentType={detail.subAgentType}
description={detail.description}
ds={ds}
/>,
];
}
if (detail.type === "worktree_setup" || detail.type === "sub_agent") return [];
if (detail.type === "edit") {
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
}
@@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => {
shellPrompt: {
color: theme.colors.foregroundMuted,
},
subAgentSessionText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
lineHeight: 18,
marginBottom: theme.spacing[2],
},
subAgentActions: {
gap: theme.spacing[1],
marginBottom: theme.spacing[2],
},
subAgentActionRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
subAgentActionTool: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
lineHeight: 18,
},
subAgentActionSummary: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
},
jsonScroll: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
@@ -455,10 +455,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
schedule: (task, delayMs) => {
const timeout = setTimeout(task, delayMs);
return () => clearTimeout(timeout);
},
});
viewedTimelineSyncRef.current = sync;
setViewedTimelineSync(serverId, sync);
@@ -292,7 +292,10 @@ const CATALOG_DATA = [
version: "0.6.13",
iconId: "minimax-code",
installLink: "https://agent.minimax.io",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
// `-p <pkg>` names the package and `mcode` names its binary. Without the
// explicit binary, npx resolves the executable from the unscoped package
// name (`code`), which this package does not publish.
command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"],
},
{
id: "minion-code",
@@ -62,7 +62,9 @@ describe("ACP provider catalog", () => {
expect(findProvider("minimax-code")).toMatchObject({
title: "MiniMax Code",
version: "0.6.13",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
// The package publishes `mcode`, not `code`, so npx needs the package and
// the binary named separately.
command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"],
});
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
});
@@ -422,6 +422,23 @@ describe("deriveAgentScreenViewState", () => {
expect(result.memory.lastReadyAgent).toBeNull();
});
it.each(["error", "retrying"] as const)("shows archived history sync %s", (status) => {
const result = deriveAgentScreenViewState({
input: {
...createBaseInput(),
agent: createAgent("agent-1"),
isArchived: true,
hasHydratedHistoryBefore: true,
visibilityCatchUpStatus: status,
},
memory: createBaseMemory(),
});
expect(expectReadyState(result.state).sync).toEqual({
status: "sync_error",
isRetrying: status === "retrying",
});
});
it("renders an archived agent before provider history is initialized", () => {
const result = deriveAgentScreenViewState({
input: {
@@ -175,10 +175,14 @@ function resolveAgentScreenSync(args: {
hadInitialSyncFailure: boolean;
}): AgentScreenReadySyncState {
const { input, hadInitialSyncFailure } = args;
if (input.isArchived) {
if (
input.isArchived &&
input.visibilityCatchUpStatus !== "error" &&
input.visibilityCatchUpStatus !== "retrying"
) {
return { status: "idle" };
}
if (!input.isConnected) {
if (!input.isConnected && !input.isArchived) {
return { status: "reconnecting" };
}
if (input.missingAgentState.kind === "error") {
-1
View File
@@ -832,7 +832,6 @@ describe("translation resources", () => {
expect(en.common.placeholders.search).toBe("Search...");
expect(en.common.empty.noResults).toBe("No results found");
expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search.");
expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity");
expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview");
});
+5 -2
View File
@@ -617,7 +617,6 @@ export const ar: TranslationResources = {
terminal: {
hostDisconnected: "Host غير متصل",
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.",
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
fileUploaded: "تم رفع الملف",
pasteFileFailed: "تعذر لصق الملف: {{reason}}",
@@ -2014,7 +2013,6 @@ export const ar: TranslationResources = {
toolCallDetails: {
error: "خطأ",
empty: "لا توجد تفاصيل إضافية متاحة",
subAgentActivity: "نشاط الوكيل الفرعي",
input: "مدخل",
output: "الإخراج",
},
@@ -2093,6 +2091,11 @@ export const ar: TranslationResources = {
},
},
providerUsage: {
checkUsage: "التحقق من الاستخدام",
checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة",
off: "التحقق من الاستخدام متوقف على {{host}}.",
turnOn: "تشغيل",
options: "الإعدادات",
refreshFailed: "تعذّر تحديث الاستخدام",
noHosts: "لا توجد مضيفات متصلة",
+5 -2
View File
@@ -614,7 +614,6 @@ export const en = {
terminal: {
hostDisconnected: "Host is not connected",
updateHost: "Update the host to use the native terminal renderer.",
restoreRequiresUpdate: "Update the host to view terminals.",
unableToSubscribe: "Unable to subscribe to terminal",
fileUploaded: "File uploaded",
pasteFileFailed: "Failed to paste file: {{reason}}",
@@ -2039,7 +2038,6 @@ export const en = {
toolCallDetails: {
error: "Error",
empty: "No additional details available",
subAgentActivity: "Sub-agent activity",
input: "Input",
output: "Output",
},
@@ -2118,6 +2116,11 @@ export const en = {
},
},
providerUsage: {
checkUsage: "Check usage",
checkUsageHint: "Applies to {{host}} on every device",
off: "Usage is off on {{host}}.",
turnOn: "Turn on",
options: "Settings",
refreshFailed: "Unable to refresh usage",
noHosts: "No connected hosts",
+5 -2
View File
@@ -623,7 +623,6 @@ export const es: TranslationResources = {
terminal: {
hostDisconnected: "Hostno está conectado",
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
restoreRequiresUpdate: "Actualiza el host para ver las terminales.",
unableToSubscribe: "No se puede suscribir al terminal",
fileUploaded: "Archivo subido",
pasteFileFailed: "No se pudo pegar el archivo: {{reason}}",
@@ -2062,7 +2061,6 @@ export const es: TranslationResources = {
toolCallDetails: {
error: "Error",
empty: "No hay detalles adicionales disponibles",
subAgentActivity: "Actividad de subagente",
input: "Aporte",
output: "Producción",
},
@@ -2141,6 +2139,11 @@ export const es: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Se aplica a {{host}} en todos los dispositivos",
off: "El uso está desactivado en {{host}}.",
turnOn: "Activar",
options: "Configuración",
refreshFailed: "No se pudo actualizar el uso",
noHosts: "No hay hosts conectados",
+5 -2
View File
@@ -623,7 +623,6 @@ export const fr: TranslationResources = {
terminal: {
hostDisconnected: "L’hôte n’est pas connecté",
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
restoreRequiresUpdate: "Mettez à jour l’hôte pour afficher les terminaux.",
unableToSubscribe: "Impossible de s’abonner au terminal",
fileUploaded: "Fichier envoyé",
pasteFileFailed: "Impossible de coller le fichier : {{reason}}",
@@ -2061,7 +2060,6 @@ export const fr: TranslationResources = {
toolCallDetails: {
error: "Erreur",
empty: "Aucun détail supplémentaire disponible",
subAgentActivity: "Activité du sous-agent",
input: "Entrée",
output: "Sortie",
},
@@ -2141,6 +2139,11 @@ export const fr: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Vérifier l’utilisation",
checkUsageHint: "S’applique à {{host}} sur tous les appareils",
off: "L’utilisation est désactivée sur {{host}}.",
turnOn: "Activer",
options: "Paramètres",
refreshFailed: "Impossible d’actualiser l’utilisation",
noHosts: "Aucun hôte connecté",
+5 -2
View File
@@ -623,7 +623,6 @@ export const ja: TranslationResources = {
terminal: {
hostDisconnected: "ホストが接続されていません",
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。",
unableToSubscribe: "ターミナルに接続できません",
fileUploaded: "ファイルをアップロードしました",
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
@@ -2034,7 +2033,6 @@ export const ja: TranslationResources = {
toolCallDetails: {
error: "エラー",
empty: "追加の詳細はありません",
subAgentActivity: "サブエージェントアクティビティ",
input: "入力",
output: "出力",
},
@@ -2114,6 +2112,11 @@ export const ja: TranslationResources = {
},
},
providerUsage: {
checkUsage: "使用状況を確認",
checkUsageHint: "すべてのデバイスで {{host}} に適用",
off: "{{host}} の使用状況確認はオフです。",
turnOn: "オンにする",
options: "設定",
refreshFailed: "使用状況を更新できません",
noHosts: "接続中のホストがありません",
+5 -2
View File
@@ -618,7 +618,6 @@ export const ko: TranslationResources = {
terminal: {
hostDisconnected: "호스트가 연결되어 있지 않습니다",
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.",
unableToSubscribe: "터미널을 구독할 수 없습니다",
fileUploaded: "파일을 업로드했습니다",
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
@@ -2024,7 +2023,6 @@ export const ko: TranslationResources = {
toolCallDetails: {
error: "오류",
empty: "추가 세부 정보가 없습니다",
subAgentActivity: "서브에이전트 활동",
input: "입력",
output: "출력",
},
@@ -2103,6 +2101,11 @@ export const ko: TranslationResources = {
},
},
providerUsage: {
checkUsage: "사용량 확인",
checkUsageHint: "모든 기기에서 {{host}}에 적용",
off: "{{host}}의 사용량 확인이 꺼져 있습니다.",
turnOn: "켜기",
options: "설정",
refreshFailed: "사용량을 새로 고칠 수 없습니다",
noHosts: "연결된 호스트가 없습니다",
+5 -2
View File
@@ -622,7 +622,6 @@ export const ptBR: TranslationResources = {
terminal: {
hostDisconnected: "Host não está conectado",
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
restoreRequiresUpdate: "Atualize o host para visualizar os terminais.",
unableToSubscribe: "Não foi possível assinar o terminal",
fileUploaded: "Arquivo enviado",
pasteFileFailed: "Falha ao colar o arquivo: {{reason}}",
@@ -2047,7 +2046,6 @@ export const ptBR: TranslationResources = {
toolCallDetails: {
error: "Erro",
empty: "Nenhum detalhe adicional disponível",
subAgentActivity: "Atividade do subagente",
input: "Entrada",
output: "Saída",
},
@@ -2126,6 +2124,11 @@ export const ptBR: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos",
off: "O uso está desativado em {{host}}.",
turnOn: "Ativar",
options: "Configurações",
refreshFailed: "Não foi possível atualizar o uso",
noHosts: "Nenhum host conectado",
+5 -2
View File
@@ -624,7 +624,6 @@ export const ru: TranslationResources = {
terminal: {
hostDisconnected: "Хост не подключён",
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.",
unableToSubscribe: "Не удалось подключиться к терминалу",
fileUploaded: "Файл загружен",
pasteFileFailed: "Не удалось вставить файл: {{reason}}",
@@ -2047,7 +2046,6 @@ export const ru: TranslationResources = {
toolCallDetails: {
error: "Ошибка",
empty: "Дополнительные сведения отсутствуют",
subAgentActivity: "Активность субагента",
input: "Входные данные",
output: "Выходные данные",
},
@@ -2126,6 +2124,11 @@ export const ru: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Проверять использование",
checkUsageHint: "Применяется к {{host}} на всех устройствах",
off: "Проверка использования отключена на {{host}}.",
turnOn: "Включить",
options: "Настройки",
refreshFailed: "Не удалось обновить использование",
noHosts: "Нет подключённых хостов",
+5 -2
View File
@@ -617,7 +617,6 @@ export const zhCN: TranslationResources = {
terminal: {
hostDisconnected: "Host 未连接",
updateHost: "请更新主机以使用原生终端渲染器。",
restoreRequiresUpdate: "更新主机以查看终端。",
unableToSubscribe: "无法订阅 Terminal",
fileUploaded: "文件已上传",
pasteFileFailed: "无法粘贴文件:{{reason}}",
@@ -1992,7 +1991,6 @@ export const zhCN: TranslationResources = {
toolCallDetails: {
error: "错误",
empty: "没有可用的更多详情",
subAgentActivity: "Sub-agent 活动",
input: "输入",
output: "输出",
},
@@ -2071,6 +2069,11 @@ export const zhCN: TranslationResources = {
},
},
providerUsage: {
checkUsage: "检查用量",
checkUsageHint: "在所有设备上应用于 {{host}}",
off: "{{host}} 的用量检查已关闭。",
turnOn: "开启",
options: "设置",
refreshFailed: "无法刷新用量",
noHosts: "没有已连接的主机",
@@ -127,6 +127,7 @@ import {
useWorkspaceSetupStore,
} from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -880,6 +881,13 @@ function shouldShowWorkspaceScreenHeader(input: {
return !input.isFocusModeEnabled || input.isMobile;
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
/**
* A pane the user acted inside owns the tab: it opens there, and an existing tab
* moves there. No pane means the open has no opinion beyond the focused pane.
@@ -1058,6 +1066,14 @@ function WorkspaceScreenContent({
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
const workspaceTerminalScopeKey = useMemo(
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
[normalizedServerId, normalizedWorkspaceId],
);
useWorkspaceTerminalSessionRetention({
scopeKey: workspaceTerminalScopeKey,
});
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const supportsProvidersSnapshot = useSessionStore(
@@ -174,7 +174,6 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
onCatchUpEnded: () => undefined,
reportError: () => undefined,
schedule: () => () => undefined,
});
// The edge session-context.tsx owns. Pointing it at subscription instead would resume every
// agent behind a restored tab on the daemon.
@@ -0,0 +1,18 @@
import { useEffect } from "react";
import {
releaseWorkspaceTerminalSession,
retainWorkspaceTerminalSession,
} from "@/terminal/runtime/workspace-terminal-session";
export function useWorkspaceTerminalSessionRetention(input: { scopeKey: string | null }): void {
useEffect(() => {
if (!input.scopeKey) {
return;
}
retainWorkspaceTerminalSession({ scopeKey: input.scopeKey });
return () => {
releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! });
};
}, [input.scopeKey]);
}
@@ -1,8 +1,8 @@
import { createTerminalResizePolicy, updateTerminalResizePolicy } from "./terminal-resize-policy";
import { describe, expect, test } from "vitest";
import {
createNativeHeadlessTerminal,
type TerminalCellRow,
type TerminalViewportState,
} from "./headless-terminal-state";
import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload";
@@ -11,8 +11,7 @@ import {
NATIVE_TERMINAL_PARSE_CHUNK_CHARS,
} from "./headless-terminal-output-drain";
import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model";
import { Terminal as ReplaySource } from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot";
const ROWS = 12;
const COLS = 80;
@@ -42,6 +41,33 @@ function createFrameRecorder() {
};
}
function lineCells(line: string, cols: number): TerminalCellRow {
const cells: TerminalCellRow = [];
for (let index = 0; index < cols; index += 1) {
cells.push({ char: line[index] ?? " ", width: 1 });
}
return cells;
}
function terminalStateFromLines(input: {
lines: string[];
rows: number;
cols: number;
cursor: TerminalViewportState["cursor"];
}) {
const grid: TerminalCellRow[] = [];
for (let row = 0; row < input.rows; row += 1) {
grid.push(lineCells(input.lines[row] ?? "", input.cols));
}
return {
rows: input.rows,
cols: input.cols,
grid,
scrollback: [],
cursor: input.cursor,
};
}
async function runScheduledWork(input: {
callbacks: Array<() => void>;
flush: Promise<void>;
@@ -54,13 +80,11 @@ async function runScheduledWork(input: {
}
async function benchmarkSnapshot(lineCount: number) {
return {
rows: ROWS,
cols: COLS,
data: new TextEncoder().encode(
createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }),
),
};
const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 });
await terminal.write(
createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }),
);
return terminal.getState();
}
function createScreenRestorer(
@@ -70,7 +94,6 @@ function createScreenRestorer(
const frameRecorder = createFrameRecorder();
let screen: TerminalScreenState | null = null;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => {
@@ -84,12 +107,7 @@ function createScreenRestorer(
return {
async restore(snapshot: Awaited<ReturnType<typeof benchmarkSnapshot>>) {
drain.replay({ type: "begin", rows: snapshot.rows, cols: snapshot.cols });
drain.replay({
type: "data",
data: snapshot.data,
});
drain.replay({ type: "end" });
drain.restoreSnapshot(snapshot);
await drain.flush();
frameRecorder.frames[0]?.();
return screen;
@@ -104,7 +122,6 @@ describe("native terminal output drain", () => {
const frameRecorder = createFrameRecorder();
const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2);
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: async (chunk) => {
writes.push(chunk);
},
@@ -153,7 +170,6 @@ describe("native terminal output drain", () => {
const paintedStates: TerminalViewportState[] = [];
const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(),
@@ -186,7 +202,6 @@ describe("native terminal output drain", () => {
const scheduledWork: Array<() => void> = [];
const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(),
@@ -195,22 +210,21 @@ describe("native terminal output drain", () => {
scheduleFrame: frameRecorder.scheduleFrame,
cancelFrame: frameRecorder.cancelFrame,
});
const restoredCursor = { row: 1, col: "restored-two".length };
const replay = new TextEncoder().encode("restored-one\r\nrestored-two");
const restoredSnapshot = terminalStateFromLines({
rows: 4,
cols: 32,
lines: ["restored-one", "restored-two"],
cursor: { row: 1, col: "restored-two".length },
});
await terminal.write("old-one\r\nold-two\r\nold-three\r\n");
drain.enqueueText("stale-queued\r\n");
drain.replay({ type: "begin" });
drain.replay({
type: "data",
data: replay,
});
drain.replay({ type: "end" });
drain.restoreText(renderTerminalSnapshotToAnsi(restoredSnapshot));
await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() });
const state = terminal.getViewportState();
expect(state.grid.map(rowText)).toEqual(["restored-one", "restored-two", "", ""]);
expect(state.cursor).toEqual(restoredCursor);
expect(state.cursor).toEqual(restoredSnapshot.cursor);
});
test("snapshot restore preserves a scrolled viewport anchor while replacing terminal state", async () => {
@@ -228,7 +242,7 @@ describe("native terminal output drain", () => {
paintedMode: screen?.scroll.mode,
paintedTopRow: rowText(screen?.viewport.grid[0] ?? []),
beforeTopRow,
restoredBottomRows: terminal.getViewportState().grid.slice(-2).map(rowText),
restoredBottomRows: terminal.getState().grid.slice(-2).map(rowText),
}).toEqual({
paintedMode: "scrolled",
paintedTopRow: beforeTopRow,
@@ -254,255 +268,3 @@ describe("native terminal output drain", () => {
});
});
});
test("replays split Unicode and ANSI with one reset then live output", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 40, scrollbackLines: 1000 });
let resets = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (text) => terminal.write(text),
reset: (size) => {
resets++;
if (size) terminal.resize(size);
terminal.reset();
},
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 4, cols: 40, retainedHistoryLines: 400 });
const bytes = new TextEncoder().encode("\x1b[31m界🙂 replay\x1b[0m\r\n");
for (let i = 0; i < bytes.length; i++)
drain.replay({ type: "data", data: bytes.subarray(i, i + 1) });
drain.replay({ type: "end" });
drain.enqueueText("live");
await drain.flush();
expect(resets).toBe(1);
expect(
terminal
.getBufferWindow({ startRow: 0, rowCount: 4 })
.rows.map((row) =>
row
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join(""),
)
.join("\n"),
).toContain("界🙂 replay");
expect(
terminal
.getBufferWindow({ startRow: 0, rowCount: 4 })
.rows.map((row) =>
row
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join(""),
)
.join("\n"),
).toContain("live");
} finally {
drain.dispose();
terminal.dispose();
}
});
test("an empty replacement replay resets after an in-flight write finishes", async () => {
let finish!: () => void;
let started!: () => void;
const writing = new Promise<void>((resolve) => {
started = resolve;
});
const blocked = new Promise<void>((resolve) => {
finish = resolve;
});
let resets = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: async () => {
started();
await blocked;
},
reset: () => {
resets++;
},
getViewportState: () => null,
onPaint: () => {},
});
try {
drain.enqueueText("old terminal");
await writing;
drain.replay({ type: "begin" });
drain.replay({ type: "end" });
finish();
await drain.flush();
expect(resets).toBe(1);
expect(drain.getQueuedChars()).toBe(0);
} finally {
finish();
drain.dispose();
}
});
test("replay geometry returns to measured layout before live writes and unchanged-layout focus", async () => {
const measured = { rows: 24, cols: 40 };
const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 1000 });
const policy = createTerminalResizePolicy(measured);
const sizes: number[] = [];
const drain = createNativeTerminalOutputDrain({
write: async (text) => {
sizes.push(terminal.getViewportState().cols);
await terminal.write(text);
},
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
restoreSize: () => terminal.resize(measured),
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 30, cols: 120 });
drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") });
drain.replay({ type: "end" });
drain.enqueueText("LIVE-" + "y".repeat(60));
await drain.flush();
expect(sizes).toEqual([120, 40]);
const focus = updateTerminalResizePolicy(policy, {
source: "claim",
size: measured,
claimToken: 1,
});
expect(focus.measuredSizeChanged).toBe(false);
expect(focus.resizeClaim).toMatchObject(measured);
expect(terminal.getViewportState()).toMatchObject(measured);
expect(terminal.getBufferBounds().cols).toBe(40);
const lines = terminal
.getViewportState()
.grid.map((row) =>
row
.map((cell) => cell.char)
.join("")
.trimEnd(),
)
.join("\n");
expect(lines).toContain("LIVE-" + "y".repeat(35) + "\n" + "y".repeat(25));
} finally {
drain.dispose();
terminal.dispose();
}
});
test.each(["clear", "replacement", "dispose"] as const)(
"cancels queued replay geometry on %s",
async (action) => {
const local = { rows: 24, cols: 40 };
const terminal = createNativeHeadlessTerminal({ ...local, scrollbackLines: 1000 });
let finish!: () => void;
const block = new Promise<void>((resolve) => {
finish = resolve;
});
let started!: () => void;
const writing = new Promise<void>((resolve) => {
started = resolve;
});
let first = true;
let restores = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {
restores++;
terminal.resize(local);
},
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
write: async (text) => {
await terminal.write(text);
if (first) {
first = false;
started();
await block;
}
},
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 30, cols: 120 });
drain.replay({ type: "data", data: new TextEncoder().encode("old") });
await writing;
local.cols = 60;
drain.resize();
expect(terminal.getViewportState().cols).toBe(120);
drain.replay({ type: "end" });
if (action === "clear") drain.clear();
else if (action === "dispose") {
drain.dispose();
drain.clear();
} else {
drain.replay({ type: "begin", rows: 20, cols: 80 });
drain.replay({ type: "data", data: new TextEncoder().encode("new") });
drain.replay({ type: "end" });
}
finish();
await drain.flush();
expect(restores).toBe(action === "dispose" ? 0 : 1);
if (action !== "dispose") expect(terminal.getViewportState()).toMatchObject(local);
} finally {
finish();
drain.dispose();
terminal.dispose();
}
},
);
test("holds captured geometry across mid-replay measurement changes", async () => {
const source = new ReplaySource({ rows: 4, cols: 8, scrollback: 100, allowProposedApi: true });
const serializer = new SerializeAddon();
source.loadAddon(serializer);
await new Promise<void>((resolve) =>
source.write("ABCDEFGH\r\n12345678\r\nabcdefgh\r\nxyz\x1b[2;8H", resolve),
);
const ansi = new TextEncoder().encode(serializer.serialize());
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
const expected = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
await expected.write(new TextDecoder().decode(ansi));
expected.resize({ rows: 4, cols: 4 });
let measured = { rows: 4, cols: 8 };
const drain = createNativeTerminalOutputDrain({
write: (chunk) => terminal.write(chunk),
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
restoreSize: () => terminal.resize(measured),
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 4, cols: 8 });
await drain.flush();
measured = { rows: 4, cols: 4 };
drain.resize();
drain.replay({ type: "data", data: ansi });
drain.replay({ type: "end" });
await drain.flush();
expect(terminal.getViewportState()).toEqual(expected.getViewportState());
const actualBounds = terminal.getBufferBounds();
const expectedBounds = expected.getBufferBounds();
expect(
terminal.getBufferWindow({
startRow: actualBounds.oldestRow,
rowCount: actualBounds.newestRow - actualBounds.oldestRow + 1,
}),
).toEqual(
expected.getBufferWindow({
startRow: expectedBounds.oldestRow,
rowCount: expectedBounds.newestRow - expectedBounds.oldestRow + 1,
}),
);
} finally {
drain.dispose();
}
});
@@ -1,3 +1,5 @@
import type { TerminalState } from "@getpaseo/protocol/messages";
import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot";
import type { TerminalViewportState } from "./headless-terminal-state";
import { nativeTerminalPerformanceNow } from "./terminal-performance";
@@ -12,9 +14,9 @@ export interface NativeTerminalParseSample {
}
export interface NativeTerminalOutputDrain {
replay(event: import("@getpaseo/client").TerminalReplayEvent): void;
enqueueText(text: string): void;
resize(): void;
restoreText(text: string): void;
restoreSnapshot(snapshot: TerminalState): void;
clear(): void;
flush(): Promise<void>;
dispose(): void;
@@ -23,8 +25,7 @@ export interface NativeTerminalOutputDrain {
export interface NativeTerminalOutputDrainOptions {
write: (chunk: string) => Promise<void>;
restoreSize: () => void;
reset: (size?: { rows: number; cols: number }) => void;
reset: () => void;
getViewportState: () => TerminalViewportState | null;
onPaint: (state: TerminalViewportState) => void;
onParse?: (sample: NativeTerminalParseSample) => void;
@@ -67,22 +68,16 @@ export function createNativeTerminalOutputDrain(
const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS;
const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN;
const idleWaiters: Array<() => void> = [];
type Operation =
| { type: "write"; text: string }
| { type: "reset"; size?: { rows: number; cols: number } }
| { type: "restore-size" };
let operations: Operation[] = [];
let queuedChars = 0;
let pendingText = "";
let drainScheduled = false;
let draining = false;
let disposed = false;
let replaying = false;
let paintFrame: number | null = null;
let generation = 0;
const replayDecoder = new TextDecoder();
let resetBeforeNextWrite = false;
function resolveIdleIfReady(): void {
if (drainScheduled || draining || operations.length > 0) {
if (drainScheduled || draining || pendingText.length > 0) {
return;
}
while (idleWaiters.length > 0) {
@@ -124,6 +119,15 @@ export function createNativeTerminalOutputDrain(
});
}
function replacePendingText(text: string): void {
generation += 1;
pendingText = text;
resetBeforeNextWrite = true;
cancelPendingPaint();
scheduleDrain();
resolveIdleIfReady();
}
async function drainPending(): Promise<void> {
if (disposed || draining) {
return;
@@ -134,23 +138,16 @@ export function createNativeTerminalOutputDrain(
let parsedChunks = 0;
try {
while (operations.length > 0 && parsedChunks < chunksPerTurn) {
if (disposed || activeGeneration !== generation) return;
const operation = operations[0]!;
if (operation.type !== "write") {
operations.shift();
if (operation.type === "reset") options.reset(operation.size);
else {
options.restoreSize();
replaying = false;
}
requestPaint();
continue;
while (pendingText.length > 0 && parsedChunks < chunksPerTurn) {
if (disposed || activeGeneration !== generation) {
return;
}
const chunk = operation.text.slice(0, chunkChars);
operation.text = operation.text.slice(chunk.length);
queuedChars -= chunk.length;
if (!operation.text) operations.shift();
if (resetBeforeNextWrite) {
options.reset();
resetBeforeNextWrite = false;
}
const chunk = pendingText.slice(0, chunkChars);
pendingText = pendingText.slice(chunk.length);
const parseStart = nativeTerminalPerformanceNow();
await options.write(chunk);
if (disposed || activeGeneration !== generation) {
@@ -159,14 +156,14 @@ export function createNativeTerminalOutputDrain(
options.onParse?.({
durationMs: nativeTerminalPerformanceNow() - parseStart,
parsedChars: chunk.length,
queuedChars,
queuedChars: pendingText.length,
});
requestPaint();
parsedChunks += 1;
}
} finally {
draining = false;
if (!disposed && operations.length > 0) {
if (!disposed && pendingText.length > 0) {
scheduleDrain();
}
resolveIdleIfReady();
@@ -174,56 +171,38 @@ export function createNativeTerminalOutputDrain(
}
return {
replay(event): void {
if (disposed) return;
if (event.type === "begin") {
replaying = true;
generation++;
replayDecoder.decode();
operations = [
{
type: "reset",
size: event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined,
},
];
queuedChars = 0;
cancelPendingPaint();
} else if (event.type === "data") {
this.enqueueText(replayDecoder.decode(event.data, { stream: true }));
} else {
this.enqueueText(replayDecoder.decode());
operations.push({ type: "restore-size" });
}
scheduleDrain();
},
enqueueText(text: string): void {
if (disposed || !text) return;
const last = operations.at(-1);
if (last?.type === "write") last.text += text;
else operations.push({ type: "write", text });
queuedChars += text.length;
if (disposed || text.length === 0) {
return;
}
pendingText += text;
scheduleDrain();
},
resize(): void {
if (disposed || replaying) return;
operations.push({ type: "restore-size" });
scheduleDrain();
restoreText(text: string): void {
if (disposed) {
return;
}
replacePendingText(text);
},
restoreSnapshot(snapshot: TerminalState): void {
if (disposed) {
return;
}
replacePendingText(renderTerminalSnapshotToAnsi(snapshot));
},
clear(): void {
if (disposed) return;
generation += 1;
operations = [{ type: "restore-size" }, { type: "reset" }];
queuedChars = 0;
replayDecoder.decode();
pendingText = "";
resetBeforeNextWrite = false;
cancelPendingPaint();
scheduleDrain();
resolveIdleIfReady();
},
flush(): Promise<void> {
if (!drainScheduled && !draining && operations.length === 0) {
if (!drainScheduled && !draining && pendingText.length === 0) {
return Promise.resolve();
}
scheduleDrain();
@@ -235,8 +214,8 @@ export function createNativeTerminalOutputDrain(
dispose(): void {
disposed = true;
generation += 1;
operations = [];
queuedChars = 0;
pendingText = "";
resetBeforeNextWrite = false;
cancelPendingPaint();
while (idleWaiters.length > 0) {
idleWaiters.shift()?.();
@@ -244,7 +223,7 @@ export function createNativeTerminalOutputDrain(
},
getQueuedChars(): number {
return queuedChars;
return pendingText.length;
},
};
}
@@ -21,6 +21,7 @@ export interface NativeHeadlessTerminalOptions {
export interface NativeHeadlessTerminal {
write(data: NativeTerminalWriteData): Promise<void>;
getState(): TerminalState;
getViewportState(input?: TerminalViewportStateInput): TerminalViewportState;
getInputModeState(): NativeHeadlessTerminalInputModeState;
getBufferBounds(): TerminalBufferBounds;
@@ -164,6 +165,35 @@ function extractRow(
return rowCells;
}
function extractGrid(terminal: HeadlessTerminalInstance): TerminalCellRow[] {
const grid: TerminalCellRow[] = [];
const baseY = terminal.buffer.active.baseY;
const reusableCell = terminal.buffer.active.getNullCell();
for (let row = 0; row < terminal.rows; row += 1) {
grid.push(extractRow(terminal, baseY + row, reusableCell));
}
return grid;
}
function extractScrollback(
terminal: HeadlessTerminalInstance,
options: { scrollbackLines?: number },
): TerminalCellRow[] {
const scrollback: TerminalCellRow[] = [];
const scrollbackLines = terminal.buffer.active.baseY;
const startRow =
typeof options.scrollbackLines === "number"
? Math.max(0, scrollbackLines - options.scrollbackLines)
: 0;
const reusableCell = terminal.buffer.active.getNullCell();
for (let row = startRow; row < scrollbackLines; row += 1) {
scrollback.push(extractRow(terminal, row, reusableCell));
}
return scrollback;
}
function extractBufferWindow(
terminal: HeadlessTerminalInstance,
oldestRow: number,
@@ -260,6 +290,19 @@ function extractInputModeState(
};
}
function extractState(
terminal: HeadlessTerminalInstance,
options: { scrollbackLines?: number },
): TerminalState {
return {
rows: terminal.rows,
cols: terminal.cols,
grid: extractGrid(terminal),
scrollback: extractScrollback(terminal, options),
cursor: extractCursorState(terminal),
};
}
function extractViewportState(
terminal: HeadlessTerminalInstance,
coordinateEpoch: number,
@@ -337,6 +380,9 @@ export function createNativeHeadlessTerminal(
});
});
},
getState(): TerminalState {
return extractState(terminal, { scrollbackLines: options.scrollbackLines });
},
getViewportState(input?: TerminalViewportStateInput): TerminalViewportState {
return extractViewportState(
terminal,
@@ -54,19 +54,16 @@ describe("native terminal scrollback retention", () => {
scrollbackLines: SCROLLBACK,
lineCount: SCROLLBACK + ROWS + 10,
});
const state = terminal.getState();
const bounds = terminal.getBufferBounds();
const historyRows = bounds.bottomViewport.firstRow - bounds.oldestRow;
expect(historyRows).toBeGreaterThan(0);
expect(historyRows).toBeLessThanOrEqual(SCROLLBACK);
expect(state.scrollback.length).toBeGreaterThan(0);
expect(state.scrollback.length).toBeLessThanOrEqual(SCROLLBACK);
expect(bounds.newestRow).toBeGreaterThan(ROWS);
expect(bounds.oldestRow).toBeGreaterThan(0);
const retained = terminal.getBufferWindow({
startRow: bounds.oldestRow,
rowCount: SCROLLBACK + ROWS + 1,
});
expect(retained.rows).toHaveLength(SCROLLBACK + ROWS);
expect(rowText(retained.rows[0]!)).toBe(expectedBenchmarkLine(bounds.oldestRow));
expect(bounds.newestRow - bounds.oldestRow + 1).toBe(
state.scrollback.length + state.grid.length,
);
});
test("observable scrollback count is bounded by configured lines", async () => {
@@ -76,9 +73,9 @@ describe("native terminal scrollback retention", () => {
scrollbackLines: SCROLLBACK,
lineCount: SCROLLBACK * 3,
});
const state = terminal.getState();
const bounds = terminal.getBufferBounds();
expect(bounds.bottomViewport.firstRow - bounds.oldestRow).toBe(SCROLLBACK);
expect(state.scrollback.length).toBe(SCROLLBACK);
});
test("scroll up moves viewport into retained history", async () => {
@@ -166,6 +166,7 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: input.scrollback ?? 10_000,
theme: {
background: "#0b0b0b",
@@ -749,11 +750,30 @@ describe("terminal emulator runtime in a real browser", () => {
const reset = vi.fn(originalReset);
terminal.reset = reset;
mounted.runtime.replay({ type: "begin", rows: terminal.rows, cols: terminal.cols });
mounted.runtime.replay({ type: "data", data: terminalOutput("prompt") });
mounted.runtime.replay({ type: "end" });
expect(reset).not.toHaveBeenCalled();
mounted.runtime.renderSnapshot({
state: {
rows: terminal.rows,
cols: terminal.cols,
scrollback: [],
grid: [
[
{ char: "p" },
{ char: "r" },
{ char: "o" },
{ char: "m" },
{ char: "p" },
{ char: "t" },
],
],
cursor: {
row: 0,
col: 6,
},
},
});
await nextFrame();
expect(reset).not.toHaveBeenCalled();
});
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
@@ -775,95 +795,3 @@ describe("terminal emulator runtime in a real browser", () => {
expect(confirm).not.toHaveBeenCalled();
});
});
describe("incremental replay lifecycle", () => {
it.each(["clear", "remount"] as const)(
"recovers live input after interrupted replay and %s",
async (cleanup) => {
const mounted = createTerminalHost({ width: 720, height: 360 });
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
mounted.root.style.width = "360px";
mounted.runtime.resize({ shouldClaim: false });
mounted.runtime.replay({ type: "data", data: terminalOutput("interrupted") });
if (cleanup === "clear")
await new Promise<void>((resolve) => mounted.runtime.clear({ onCommitted: resolve }));
else {
mounted.runtime.unmount();
mounted.runtime.mount({
root: mounted.root,
host: mounted.host,
scrollback: 1000,
theme: {},
});
}
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
);
getBrowserTerminal().input("user-input", true);
expect(mounted.inputs).toContain("user-input");
expect(getBrowserTerminal().cols).toBeLessThan(60);
},
);
it("suppresses generated replies until queued replay chunks commit after end", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
mounted.runtime.replay({ type: "begin" });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("committed"), onCommitted: resolve }),
);
expect(mounted.inputs).toEqual([]);
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("\x1b]4;1;?\x07"), onCommitted: resolve }),
);
expect(mounted.inputs).toHaveLength(1);
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
});
it("restores fitted web geometry after replay before live output", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await settleMountRefits();
const local = latestSize(mounted.sizes);
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
mounted.runtime.replay({ type: "data", data: terminalOutput("x".repeat(100)) });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }),
);
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
expect(getBrowserTerminal().cols).toBeLessThan(120);
expect(getBrowserTerminal().rows).toBe(local.rows);
mounted.runtime.resize({ forceClaim: true, shouldClaim: true });
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
});
it("defers layout fit until all replay chunks commit", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await settleMountRefits();
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
await new Promise<void>((resolve) =>
mounted.runtime.write({
data: terminalOutput("first"),
suppressInput: true,
onCommitted: resolve,
}),
);
expect(getBrowserTerminal().cols).toBe(120);
mounted.root.style.width = "360px";
mounted.runtime.resize({ shouldClaim: true, forceClaim: true, forceRefresh: true });
await nextFrame();
expect(getBrowserTerminal().cols).toBe(120);
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b[2;110Hsecond") });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }),
);
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
expect(getBrowserTerminal().cols).toBeLessThan(60);
expect(latestSize(mounted.sizes)).toMatchObject({ shouldClaim: true, forceClaim: true });
mounted.runtime.resize({ shouldClaim: true, forceClaim: true });
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
});
@@ -302,14 +302,25 @@ describe("terminal-emulator-runtime", () => {
expect(committed).toEqual(["first", "second", "clear", "third"]);
});
it("unblocks queued replay writes if a begin barrier never commits", () => {
it("falls back to timeout commit for a barrier op when the gate sentinel never fires", () => {
vi.useFakeTimers();
const { runtime, writeTexts } = createRuntimeWithTerminal();
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("stuck") });
expect(writeTexts).toEqual(["\x1bc"]);
vi.advanceTimersByTime(5000);
expect(writeTexts).toEqual(["\x1bc", "stuck"]);
const { runtime, terminal } = createRuntimeWithTerminal();
const onCommitted = vi.fn();
// restoreOutput is a barrier; if xterm never commits the gate sentinel, the barrier still
// applies (and commits) after the 5s safety timeout instead of stalling forever.
runtime.restoreOutput({
data: terminalOutput("stuck"),
onCommitted,
});
expect(onCommitted).not.toHaveBeenCalled();
expect(terminal.resetCalls).toBe(0);
vi.advanceTimersByTime(5_000);
// The barrier now runs (write submitted) but its own commit still waits on xterm; advance
// again to fire the barrier's own write timeout.
vi.advanceTimersByTime(5_000);
expect(onCommitted).toHaveBeenCalledTimes(1);
});
it("reports input mode changes from terminal output and resets them on snapshots", () => {
@@ -332,7 +343,18 @@ describe("terminal-emulator-runtime", () => {
});
runtime.write({ data: terminalOutput("\x1b[>7u") });
runtime.replay({ type: "begin", rows: 2, cols: 8 });
runtime.renderSnapshot({
state: {
rows: 2,
cols: 8,
scrollback: [],
grid: [[{ char: "$" }, { char: " " }]],
cursor: {
row: 0,
col: 2,
},
},
});
// The plain write reports kitty flags synchronously during drain; the snapshot resets
// them only after its barrier gate (the sentinel write callback) resolves.
expect(inputModeChanges).toEqual([
@@ -388,18 +410,40 @@ describe("terminal-emulator-runtime", () => {
expect(committed).toEqual(["second", "first"]);
});
it("keeps replay reset and data after preceding writes, without interleaving", () => {
it("applies a snapshot after the writes ahead of it, never interleaving the reset", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.write({ data: terminalOutput("before-a") });
runtime.write({ data: terminalOutput("before-b") });
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("snap") });
runtime.replay({ type: "end" });
const committed: string[] = [];
runtime.write({
data: terminalOutput("before-a"),
onCommitted: () => {
committed.push("before-a");
},
});
runtime.write({
data: terminalOutput("before-b"),
onCommitted: () => {
committed.push("before-b");
},
});
runtime.restoreOutput({
data: terminalOutput("snap"),
onCommitted: () => {
committed.push("snap");
},
});
// Plain writes are submitted up front; the snapshot's reset+write has not run yet.
expect(writeTexts).toEqual(["before-a", "before-b"]);
// writeCallbacks: [0]=before-a, [1]=before-b, [2]=barrier gate sentinel.
// The snapshot is gated behind the prior writes; resolving the gate runs it after them.
writeCallbacks[2]?.();
expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc"]);
expect(writeTexts).toEqual(["before-a", "before-b", "csnap"]);
// Snapshot commit fires only after its own write callback, strictly after the writes.
writeCallbacks[3]?.();
expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc", "snap"]);
expect(committed).toEqual(["snap"]);
expect(terminal.resetCalls).toBe(0);
});
@@ -412,7 +456,7 @@ describe("terminal-emulator-runtime", () => {
// No plain writes precede this barrier (mount), so there is nothing to gate: it starts
// at once with no sentinel, flipping suppressInput synchronously.
runtime.replay({ type: "begin" });
runtime.restoreOutput({ data: terminalOutput("snapshot") });
expect(readSuppressInput()).toBe(true);
// writeCallbacks[0] is the barrier's own snapshot write; committing it restores input.
@@ -427,7 +471,7 @@ describe("terminal-emulator-runtime", () => {
// A plain write is now ungated, so the following barrier must wait on the sentinel.
runtime.write({ data: terminalOutput("output") });
runtime.replay({ type: "begin" });
runtime.restoreOutput({ data: terminalOutput("snapshot") });
// The plain write commits first. Only the following sentinel opens the barrier.
expect(readSuppressInput()).toBe(false);
@@ -461,40 +505,57 @@ describe("terminal-emulator-runtime", () => {
expect(onCommittedB).toHaveBeenCalledTimes(1);
});
it("clears ungated writes on unmount so remount replay begins immediately", () => {
it("clears ungated writes on unmount so remount barriers apply immediately", () => {
const { runtime } = createRuntimeWithTerminal();
runtime.write({ data: terminalOutput("before unmount") });
runtime.unmount();
const remounted = attachStubTerminal(runtime);
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
expect(remounted.writeTexts).toEqual(["\x1bc"]);
remounted.writeCallbacks[0]?.();
expect(remounted.writeTexts).toEqual(["\x1bc", "restored screen"]);
runtime.restoreOutput({ data: terminalOutput("restored screen") });
expect(remounted.writeTexts).toEqual(["\u001bcrestored screen"]);
expect(remounted.writeCallbacks).toHaveLength(1);
});
it("parses replay chunks serially without synchronously resetting the terminal", () => {
it("replays snapshots through a single write without first painting a reset terminal", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.replay({ type: "begin", rows: 2, cols: 8 });
runtime.replay({ type: "data", data: terminalOutput("hi") });
runtime.replay({ type: "data", data: terminalOutput("\r\n$") });
runtime.replay({ type: "end" });
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toEqual(["\x1bc"]);
runtime.renderSnapshot({
state: {
rows: 2,
cols: 8,
scrollback: [],
grid: [
[{ char: "h" }, { char: "i" }],
[{ char: "$" }, { char: " " }],
],
cursor: {
row: 1,
col: 2,
},
},
});
// The snapshot is a barrier; its write applies after the gate sentinel (writeCallbacks[0]).
writeCallbacks[0]?.();
expect(writeTexts).toEqual(["\x1bc", "hi"]);
writeCallbacks[1]?.();
expect(writeTexts).toEqual(["\x1bc", "hi", "\r\n$"]);
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toHaveLength(1);
expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true);
expect(writeTexts[0]).toContain("hi");
});
it("restores worker-rendered ANSI through the replay lifecycle", () => {
it("restores server-rendered ANSI snapshots through the snapshot write path", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
runtime.replay({ type: "end" });
runtime.restoreOutput({ data: terminalOutput("restored screen") });
// restoreOutput is a barrier (suppressInput); it applies after the gate sentinel.
writeCallbacks[0]?.();
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toEqual(["\x1bc", "restored screen"]);
expect(writeTexts).toEqual(["\u001bcrestored screen"]);
});
it("forces a refit when resize is requested", () => {
@@ -7,6 +7,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
type TerminalInputModeState,
TerminalInputModeTracker,
@@ -23,6 +24,7 @@ import {
resolveMacTerminalEditingShortcut,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
import {
createTerminalLocalFileLinkProvider,
type TerminalLocalFileLinkSource,
@@ -53,6 +55,7 @@ const FIND_HIGHLIGHT_LIMIT = 20_000;
export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
initialSnapshot: TerminalState | null;
scrollback: number;
theme: ITheme;
fontFamily?: string;
@@ -131,7 +134,7 @@ interface TerminalEmulatorRuntimeDisposables {
}
interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot" | "fit";
type: "write" | "clear" | "snapshot";
data: TerminalOutputData;
rows?: number;
cols?: number;
@@ -277,7 +280,6 @@ export class TerminalEmulatorRuntime {
},
};
private replayResize: TerminalResizeRequest | null = null;
private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
@@ -650,7 +652,6 @@ export class TerminalEmulatorRuntime {
window.__paseoTerminal = terminal;
const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => {
if (this.deferReplayResize(resizeInput)) return;
const forceRefresh = resizeInput?.forceRefresh ?? false;
const shouldClaim = resizeInput?.shouldClaim ?? true;
const forceClaim = resizeInput?.forceClaim ?? false;
@@ -776,6 +777,10 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
}, 0);
if (input.initialSnapshot) {
this.renderSnapshot({ state: input.initialSnapshot });
}
this.processOutputQueue();
const disposables: TerminalEmulatorRuntimeDisposables = {
@@ -893,19 +898,20 @@ export class TerminalEmulatorRuntime {
onFilePaste?.(file);
}
replay(event: import("@getpaseo/client").TerminalReplayEvent): void {
if (event.type === "begin") {
this.replayResize ??= { shouldClaim: false };
this.restoreOutput({ data: EMPTY_TERMINAL_OUTPUT, rows: event.rows, cols: event.cols });
} else if (event.type === "data") {
this.write({ data: event.data, suppressInput: true });
} else {
this.outputOperations.push({ type: "fit", data: EMPTY_TERMINAL_OUTPUT });
this.processOutputQueue();
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void {
if (!input.state) {
this.clear(input);
return;
}
this.restoreOutput({
data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)),
rows: input.state.rows,
cols: input.state.cols,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
}
private restoreOutput(input: {
restoreOutput(input: {
data: TerminalOutputData;
rows?: number;
cols?: number;
@@ -922,16 +928,6 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
}
private deferReplayResize(input?: TerminalResizeRequest): boolean {
if (!this.replayResize) return false;
this.replayResize = {
forceRefresh: this.replayResize.forceRefresh || input?.forceRefresh,
shouldClaim: this.replayResize.shouldClaim || (input?.shouldClaim ?? true),
forceClaim: this.replayResize.forceClaim || input?.forceClaim,
};
return true;
}
resize(input?: TerminalResizeRequest): void {
this.fitAndEmitResize?.(input);
}
@@ -1051,7 +1047,6 @@ export class TerminalEmulatorRuntime {
commit();
}
this.hasUngatedWrites = false;
this.replayResize = null;
this.cleanup?.();
this.cleanup = null;
@@ -1181,40 +1176,16 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
};
if (operation.type === "fit") {
// xterm resize flushes its write buffer synchronously. Leave the parser's
// commit callback before fitting, or that flush consumes the next write.
queueMicrotask(() => {
if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return;
const resize = this.replayResize ?? { shouldClaim: false };
this.replayResize = null;
this.fitAndEmitResize?.(resize);
finalizeOperation(operation);
});
return;
}
if (operation.type === "clear") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
if (!this.replayResize) {
finalizeOperation(operation);
return;
}
queueMicrotask(() => {
if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return;
const resize = this.replayResize ?? { shouldClaim: false };
this.replayResize = null;
this.fitAndEmitResize?.(resize);
finalizeOperation(operation);
});
finalizeOperation(operation);
return;
}
if (operation.type === "snapshot") {
this.replayResize ??= { shouldClaim: false };
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
@@ -31,6 +31,7 @@ function mount(options?: { isMac: boolean }) {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 1000,
theme: { background: "#ffffff", foreground: "#111111" },
});
@@ -38,6 +38,7 @@ function mountTerminal(left: number): TerminalView {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 1000,
fontFamily: "monospace",
fontSize: 16,
@@ -1,14 +1,15 @@
import { Terminal as ReplaySource } from "@xterm/xterm";
import { SerializeAddon } from "@xterm/addon-serialize";
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it } from "vitest";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
// Regression: "streaming pino log, resized the Paseo terminal, old logs stayed
// narrow and new logs drew on top of the old ones."
//
// Backpressure triggers an ANSI replay serialized directly from xterm. Replayed
// soft-wrapped lines must reflow on resize like live output.
// A heavy stream overflows MAX_TERMINAL_OUTPUT_FRAME_BYTES, so the server sends a
// SNAPSHOT mid-stream. The client restores it via renderTerminalSnapshotToAnsi.
// When the snapshot carries per-row soft-wrap flags, restored long lines must
// reflow on resize just like live output — not freeze at the snapshot width.
interface Mounted {
host: HTMLDivElement;
@@ -56,6 +57,7 @@ function mount(width: number, height: number): Mounted {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 10_000,
theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" },
});
@@ -88,18 +90,9 @@ function someRowContains(term: InspectableTerminal, text: string): boolean {
return dumpRows(term).some((r) => r.text.includes(text));
}
function replayCommitted(
runtime: TerminalEmulatorRuntime,
state: { rows: number; cols: number; data: Uint8Array },
) {
function renderSnapshotCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) {
return new Promise<void>((resolve) => {
runtime.replay({ type: "begin", rows: state.rows, cols: state.cols });
runtime.replay({
type: "data",
data: state.data,
});
runtime.replay({ type: "end" });
runtime.write({ data: new Uint8Array([0]), onCommitted: resolve });
runtime.renderSnapshot({ state, onCommitted: resolve });
});
}
@@ -109,18 +102,31 @@ function writeCommitted(runtime: TerminalEmulatorRuntime, data: Uint8Array) {
});
}
// Serialize one long pino line from a real server-width xterm buffer.
// The replay must retain its soft-wrap boundaries for later client reflow.
async function buildWrappedSnapshot(cols: number) {
const source = new ReplaySource({ rows: 24, cols, allowProposedApi: true });
const serializer = new SerializeAddon();
source.loadAddon(serializer);
try {
await new Promise<void>((resolve) => source.write(pinoLine(0), resolve));
return { rows: 24, cols, data: new TextEncoder().encode(serializer.serialize()) };
} finally {
source.dispose();
// Build a server-style snapshot of one long pino line that the SERVER soft-wrapped
// at `cols`, represented as multiple grid rows (exactly what the daemon stores in
// its headless xterm and ships in a snapshot).
function buildWrappedSnapshot(cols: number): TerminalState {
const longLine = `[server] [15:30:00.123] TRACE: provider.claude.raw_event {"module":"agent","seq":1,"sessionId":"ed8972f4-d3be-45d0-992c-631f8f1ed04e","turnId":"foreground-turn-1","payload":"${"A".repeat(40)}"}`;
const grid: TerminalState["grid"] = [];
for (let i = 0; i < longLine.length; i += cols) {
const chunk = longLine.slice(i, i + cols);
grid.push([...chunk].map((char) => ({ char })));
}
// The fixed daemon ships per-row soft-wrap flags: every row of the logical line
// continues onto the next except the last.
const gridWrapped = grid.map((_, index) => index < grid.length - 1);
// Cursor sits just below the restored content, exactly where the daemon's
// headless terminal leaves it after a line break, so streamed output appends
// cleanly below instead of overwriting the restored rows.
return {
rows: 24,
cols,
scrollback: [],
scrollbackWrapped: [],
grid,
gridWrapped,
cursor: { row: grid.length, col: 0 },
};
}
function pinoLine(seq: number): string {
@@ -135,14 +141,15 @@ afterEach(() => {
});
describe("terminal resize reflow repro (Paseo terminal)", () => {
it("replayed soft-wrapped rows reflow after the terminal grows", async () => {
it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => {
await page.viewport(1600, 700);
const m = mount(560, 360); // ~70 cols
await waitFor(() => m.terminal.cols > 40);
const narrowCols = m.terminal.cols;
// 1) A mid-stream ANSI replay arrives, preserving wraps at the captured width.
await replayCommitted(m.runtime, await buildWrappedSnapshot(narrowCols));
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the
// long line wrapped at the server width as separate grid rows.
await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]"));
// 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
@@ -4,27 +4,25 @@ import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
describe("terminal restore options", () => {
it("omits restore options for daemons without terminal restore modes", () => {
expect(() =>
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: false,
canClaimSize: true,
size: { rows: 24, cols: 80 },
}),
).toThrow("Update the host to view terminals.");
).toBeUndefined();
});
it("requests visible snapshot restore with configured scrollback for capable daemons", () => {
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: true,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 10000,
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
});
});
@@ -32,39 +30,26 @@ describe("terminal restore options", () => {
it("omits size until the terminal has been measured", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: true,
size: null,
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 10000,
scrollbackLines: 200,
});
});
it("does not let a background attach resize the PTY", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: false,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 10000,
scrollbackLines: 200,
});
});
});
it.each([0, 5, 400, 10000])("requests exactly %s configured history rows", (scrollbackLines) => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
scrollbackLines,
canClaimSize: false,
size: null,
}),
).toEqual({ mode: "visible-snapshot", scrollbackLines });
});
@@ -1,23 +1,23 @@
import { i18n } from "@/i18n/i18next";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
export interface ResolveTerminalRestoreOptionsInput {
supportsTerminalRestoreModes: boolean;
scrollbackLines: number;
canClaimSize: boolean;
size: { rows: number; cols: number } | null;
}
export function resolveTerminalRestoreOptions(
input: ResolveTerminalRestoreOptionsInput,
): NonNullable<SubscribeTerminalRequest["restore"]> {
): SubscribeTerminalRequest["restore"] | undefined {
if (!input.supportsTerminalRestoreModes) {
throw new Error(i18n.t("workspace.terminal.restoreRequiresUpdate"));
return undefined;
}
return {
mode: "visible-snapshot",
scrollbackLines: input.scrollbackLines,
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
...(input.canClaimSize && input.size ? { size: input.size } : {}),
};
}
@@ -2,7 +2,7 @@ import { Terminal as HeadlessTerminal } from "@xterm/headless";
import { Terminal as ClientTerminal } from "@xterm/xterm";
import { describe, expect, it } from "vitest";
import { SerializeAddon } from "@xterm/addon-serialize";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
interface SnapshotCell {
char: string;
@@ -122,7 +122,7 @@ function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): S
}
describe("terminal-snapshot", () => {
it("replays upstream serialized ANSI into a client xterm with matching grid, scrollback, and cursor presentation", async () => {
it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor presentation", async () => {
const source = new HeadlessTerminal({
rows: 4,
cols: 12,
@@ -138,8 +138,6 @@ describe("terminal-snapshot", () => {
await writeToTerminal(source, "\u001b[2 q");
await writeToTerminal(source, "\u001b[?25l");
const serializer = new SerializeAddon();
source.loadAddon(serializer);
const snapshot = extractState(source);
const client = new ClientTerminal({
@@ -149,7 +147,7 @@ describe("terminal-snapshot", () => {
scrollback: 100,
});
await writeToTerminal(client, serializer.serialize() + "\x1b[2 q\x1b[?25l");
await writeToTerminal(client, renderTerminalSnapshotToAnsi(snapshot));
expect(extractState(client)).toEqual(snapshot);
});
@@ -0,0 +1 @@
export { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
@@ -1,4 +1,3 @@
import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
@@ -8,16 +7,34 @@ import {
type TerminalStreamControllerStatus,
} from "./terminal-stream-controller";
interface TerminalSnapshot {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
}
function terminalCellText(cell: { char: string }): string {
return cell.char;
}
function terminalRowText(row: Array<{ char: string }>): string {
return row.map(terminalCellText).join("");
}
function terminalSnapshotText(state: TerminalSnapshot): string {
return state.grid.map(terminalRowText).join("\n");
}
function terminalOutput(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
type StreamEvent = import("@getpaseo/client").TerminalStreamEvent;
type TerminalStreamEvent = StreamEvent extends infer Event
? Event extends StreamEvent
? Omit<Event, "subscriptionId">
: never
: never;
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot }
| { terminalId: string; type: "restore"; data: Uint8Array };
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly updates = new Set<(message: SessionOutboundMessage) => void>();
@@ -98,23 +115,32 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const replays: import("@getpaseo/client").TerminalReplayEvent[] = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const exits: string[] = [];
const controller = new TerminalStreamController({
client,
onReplay: ({ replay }) => replays.push(replay),
onExit: (terminalId) => exits.push(terminalId),
getPreferredSize: () => ({ rows: 24, cols: 80 }),
onOutput: (output) => {
outputs.push(output);
},
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
onStatusChange: (status) => {
statuses.push(status);
},
});
return { client, controller, outputs, replays, statuses, exits };
return { client, controller, outputs, restores, snapshots, statuses, exits };
}
async function flushAsyncWork(): Promise<void> {
@@ -123,20 +149,24 @@ async function flushAsyncWork(): Promise<void> {
}
describe("terminal-stream-controller", () => {
it("subscribes, resizes, and forwards replay/output events", async () => {
it("subscribes, resizes, and forwards snapshot/output events", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
{ type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 },
{ type: "data", data: terminalOutput("hello") },
{ type: "end" },
];
for (const event of replay)
harness.client.emit({ terminalId: "term-1", type: "replay", replay: event });
harness.client.emit({
terminalId: "term-1",
type: "snapshot",
state: {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
},
});
const outputData = terminalOutput(" world");
harness.client.emit({
terminalId: "term-1",
@@ -148,7 +178,7 @@ describe("terminal-stream-controller", () => {
expect(harness.client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]);
expect(harness.replays).toEqual(replay);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.outputs[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.statuses.at(-1)).toEqual({
@@ -206,19 +236,23 @@ describe("terminal-stream-controller", () => {
size: { rows: 24, cols: 80 },
}),
onOutput: (output) => harness.outputs.push(output),
onReplay: ({ replay }) => harness.replays.push(replay),
onRestore: (restore) => harness.restores.push(restore),
onSnapshot: ({ terminalId, state }) => {
harness.snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
});
controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
const restoreData = terminalOutput("restored");
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
{ type: "begin" },
{ type: "data", data: restoreData },
{ type: "end" },
];
for (const event of replay)
client.emit({ terminalId: "term-1", type: "replay", replay: event });
client.emit({
terminalId: "term-1",
type: "restore",
data: restoreData,
});
controller.dispose();
expect(client.subscribeCalls).toEqual([
@@ -236,7 +270,8 @@ describe("terminal-stream-controller", () => {
expect(client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]);
expect(harness.replays).toEqual(replay);
expect(harness.restores[0]?.data).toBe(restoreData);
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
expect(harness.outputs).toEqual([]);
});
@@ -284,28 +319,3 @@ it("reports observation failure without declaring the PTY exited, then allows re
expect(h.statuses.at(-1)?.error).toBe(null);
h.controller.dispose();
});
it("does not resize or attach on a JSON-era host and reports the required update", () => {
const client = new FakeTerminalStreamClient();
const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 40 }),
onOutput: () => {},
getRestoreOptions: () =>
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: false,
scrollbackLines: 10000,
canClaimSize: true,
size: { rows: 24, cols: 40 },
}),
onStatusChange: (status) => statuses.push(status),
});
controller.setTerminal({ terminalId: "old-host-terminal" });
expect(client.subscribeCalls).toEqual([]);
expect(client.resizeCalls).toEqual([]);
expect(statuses.at(-1)).toMatchObject({
isAttaching: false,
error: "Update the host to view terminals.",
});
});
@@ -1,5 +1,5 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalOutputData } from "./terminal-emulator-runtime";
import { i18n } from "@/i18n/i18next";
@@ -23,11 +23,9 @@ export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onExit?: (terminalId: string) => void;
onReplay?: (input: {
terminalId: string;
replay: import("@getpaseo/client").TerminalReplayEvent;
}) => void;
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
}
@@ -45,11 +43,17 @@ export class TerminalStreamController {
if (this.disposed || event.terminalId !== this.terminalId) {
return;
}
if (event.type === "replay") {
this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay });
if (event.type === "snapshot") {
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
return;
}
if (event.type === "output" && event.data.length > 0) {
if (event.type === "restore") {
if (event.data.length > 0) {
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
}
return;
}
if (event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
}
};
@@ -66,10 +70,10 @@ export class TerminalStreamController {
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return;
}
const restore = this.options.getRestoreOptions?.();
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
let subscription: ReturnType<DaemonClient["observeTerminal"]>;
try {
const restore = this.options.getRestoreOptions?.();
const preferredSize = restore?.size ?? this.options.getPreferredSize();
if (preferredSize)
this.options.client.sendTerminalInput(nextTerminalId, {
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
getWorkspaceTerminalSession,
releaseWorkspaceTerminalSession,
retainWorkspaceTerminalSession,
} from "./workspace-terminal-session";
describe("workspace-terminal-session", () => {
it("returns the same workspace session instance for the same scope", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
});
expect(second).toBe(first);
});
it("preserves snapshots across repeated lookups", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
});
first.snapshots.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
});
expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
});
});
it("evicts workspace terminal session state when the retain count returns to zero", () => {
const scopeKey = "workspace-release";
const first = getWorkspaceTerminalSession({
scopeKey,
});
first.snapshots.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
retainWorkspaceTerminalSession({ scopeKey });
releaseWorkspaceTerminalSession({ scopeKey });
const second = getWorkspaceTerminalSession({
scopeKey,
});
expect(second).not.toBe(first);
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
});
});
@@ -0,0 +1,79 @@
import type { TerminalState } from "@getpaseo/protocol/messages";
export interface WorkspaceTerminalSnapshots {
get: (input: { terminalId: string }) => TerminalState | null;
set: (input: { terminalId: string; state: TerminalState }) => void;
clear: (input: { terminalId: string }) => void;
prune: (input: { terminalIds: string[] }) => void;
}
export interface WorkspaceTerminalSession {
scopeKey: string;
snapshots: WorkspaceTerminalSnapshots;
}
interface WorkspaceTerminalSessionRecord {
snapshotByTerminalId: Map<string, TerminalState>;
session: WorkspaceTerminalSession;
}
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
const refCountByScopeKey = new Map<string, number>();
function createSnapshots(input: {
snapshotByTerminalId: Map<string, TerminalState>;
}): WorkspaceTerminalSnapshots {
return {
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
set: ({ terminalId, state }) => {
input.snapshotByTerminalId.set(terminalId, state);
},
clear: ({ terminalId }) => {
input.snapshotByTerminalId.delete(terminalId);
},
prune: ({ terminalIds }) => {
const terminalIdSet = new Set(terminalIds);
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
input.snapshotByTerminalId.delete(terminalId);
}
}
},
};
}
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
const existing = sessionsByScopeKey.get(input.scopeKey);
if (existing) {
return existing.session;
}
const snapshotByTerminalId = new Map<string, TerminalState>();
const session: WorkspaceTerminalSession = {
scopeKey: input.scopeKey,
snapshots: createSnapshots({
snapshotByTerminalId,
}),
};
sessionsByScopeKey.set(input.scopeKey, {
snapshotByTerminalId,
session,
});
return session;
}
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
refCountByScopeKey.set(input.scopeKey, current + 1);
}
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
if (current > 1) {
refCountByScopeKey.set(input.scopeKey, current - 1);
return;
}
refCountByScopeKey.delete(input.scopeKey);
sessionsByScopeKey.delete(input.scopeKey);
}
@@ -57,7 +57,6 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
}
@@ -617,7 +616,6 @@ describe("create handoff lifetime", () => {
},
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
@@ -90,7 +90,7 @@ test("visible catch-up settles before hidden open chats start fetching", async (
world.sync.dispose();
});
test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => {
test("a failed visible catch-up releases hidden chats without bypassing its error", async () => {
const world = new TimelineWorld();
world.openedEarlier(["a-hidden"]);
world.sync.replaceVisibleAgentIds("pane", ["z-visible"]);
@@ -103,7 +103,7 @@ test("a failed visible catch-up releases hidden chats without bypassing its retr
hidden.respond({ hasNewer: false });
await world.nextError();
world.expectNoPendingFetch();
(await world.nextRetry())();
world.sync.retryVisibleAgentTimeline("z-visible");
(await world.nextFetch("z-visible")).respond({ hasNewer: false });
world.sync.dispose();
});
@@ -200,20 +200,6 @@ class TimelineWorld {
const waiter = this.errorWaiters.shift();
if (waiter) waiter(this.errors.at(-1) ?? "");
},
schedule: (task, delayMs) => {
const scheduled = { task, delayMs };
this.scheduled.push(scheduled);
const waiterIndex = this.retryWaiters.findIndex((waiter) => waiter.delayMs === delayMs);
if (waiterIndex >= 0) {
const [waiter] = this.retryWaiters.splice(waiterIndex, 1);
this.scheduled.splice(this.scheduled.indexOf(scheduled), 1);
waiter.resolve(task);
}
return () => {
const index = this.scheduled.indexOf(scheduled);
if (index >= 0) this.scheduled.splice(index, 1);
};
},
});
show(sourceId: string, agentIds: string[]): void {
@@ -235,12 +221,6 @@ class TimelineWorld {
}> = [];
private readonly errorWaiters: Array<(message: string) => void> = [];
private readonly cacheRequestWaiters: Array<(agentId: string) => void> = [];
private readonly scheduled: Array<{ task: () => void; delayMs: number }> = [];
private readonly retryWaiters: Array<{
delayMs: number;
resolve(retry: () => void): void;
}> = [];
get pendingFetchCount(): number {
return this.fetches.length;
}
@@ -300,22 +280,6 @@ class TimelineWorld {
return new Promise((resolve) => this.errorWaiters.push(resolve));
}
nextRetry(delayMs = 1_000): Promise<() => void> {
const index = this.scheduled.findIndex((entry) => entry.delayMs === delayMs);
if (index >= 0) return Promise.resolve(this.scheduled.splice(index, 1)[0].task);
return new Promise((resolve) => this.retryWaiters.push({ delayMs, resolve }));
}
elapse(elapsedMs: number): void {
const due = this.scheduled.filter((entry) => entry.delayMs <= elapsedMs);
this.scheduled.splice(
0,
this.scheduled.length,
...this.scheduled.filter((entry) => entry.delayMs > elapsedMs),
);
for (const entry of due) entry.task();
}
private releaseMembershipWaiter(): void {
const waiter = this.membershipWaiters.shift();
if (!waiter) return;
@@ -483,6 +447,28 @@ test("reports a chat current only once its parked follow-up page completes", asy
world.sync.dispose();
});
test("a gap during manual retry is recovered before reporting ready", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).fail("unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
const retry = await world.nextFetch("agent-a");
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 });
retry.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.pendingFetchCount).toBe(1));
const recovery = await world.nextFetch("agent-a");
expect(recovery.request).toMatchObject({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 9 },
});
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
recovery.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
@@ -780,6 +766,21 @@ test("overlapping sources deduplicate membership and retain open hidden chats",
world.expectNoPendingFetch();
});
test("failed timeline fetch does not schedule a retry", async () => {
vi.useFakeTimers();
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).fail("unavailable");
await world.nextError();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(120_000);
world.expectNoPendingFetch();
world.expectNoPendingMembership();
vi.useRealTimers();
});
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -788,12 +789,12 @@ test("a failed catch-up reports once and retries through the explicit retry poli
membership.succeed();
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
const error = await world.nextError();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryCatchUp();
world.sync.retryVisibleAgentTimeline("agent-a");
const retry = await world.nextFetch("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
retry.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
@@ -804,35 +805,6 @@ test("a failed catch-up reports once and retries through the explicit retry poli
world.expectNoPendingMembership();
});
test("a failed catch-up retries with exponential backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const first = await world.nextFetch("agent-a");
first.fail("timeline unavailable");
const [firstError, retryAfterFirstFailure] = await Promise.all([
world.nextError(),
world.nextRetry(),
]);
expect(firstError).toBe("timeline unavailable");
retryAfterFirstFailure();
const second = await world.nextFetch("agent-a");
second.fail("timeline unavailable");
await world.nextError();
const retryAfterSecondFailure = await world.nextRetry(2_000);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryAfterSecondFailure();
const third = await world.nextFetch("agent-a");
third.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("manual retries can immediately re-attempt a failed catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -842,7 +814,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextRetry();
await world.nextError();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
world.sync.retryVisibleAgentTimeline("agent-a");
@@ -853,7 +825,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => {
test("redeclaring unchanged visibility does not bypass catch-up error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
@@ -862,9 +834,11 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextRetry();
await world.nextError();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setActive(false);
world.sync.setActive(true);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -880,7 +854,7 @@ test("a manual retry that fails returns to the error state", async () => {
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextRetry();
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
@@ -943,20 +917,34 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as
gapPage.respond({ hasNewer: false });
});
test("membership failure autonomously retries without another visibility declaration", async () => {
test("failed membership does not schedule a retry", async () => {
vi.useFakeTimers();
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).fail("unavailable");
await world.nextError();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(120_000);
world.expectNoPendingFetch();
world.expectNoPendingMembership();
vi.useRealTimers();
});
test("membership failure waits for manual retry", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
const error = await world.nextError();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryMembership();
world.sync.retryVisibleAgentTimeline("agent-a");
const retry = await world.nextMembership();
retry.succeed();
const catchUp = await world.nextFetch("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
catchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
@@ -967,44 +955,68 @@ test("membership failure autonomously retries without another visibility declara
});
});
test("membership failures retry with exponential backoff", async () => {
test("retrying membership restores a previously loaded chat", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const first = await world.nextMembership();
first.fail("subscription unavailable");
const [firstError, retryAfterFirstFailure] = await Promise.all([
world.nextError(),
world.nextRetry(),
]);
expect(firstError).toBe("subscription unavailable");
retryAfterFirstFailure();
const second = await world.nextMembership();
second.fail("subscription unavailable again");
const retryAfterSecondFailure = await world.nextRetry(2_000);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryAfterSecondFailure();
const retry = await world.nextMembership();
retry.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
world.show("workspace", ["agent-b"]);
(await world.nextMembership()).fail("subscription unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-b")).respond({ hasNewer: false });
const resumed = await world.nextFetch("agent-a");
expect(resumed.request).toMatchObject({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 10 },
});
resumed.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("redeclaring unchanged visibility does not bypass membership backoff", async () => {
test("a fetch that finishes after membership failure cannot clear the error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const inFlight = await world.nextFetch("agent-a");
world.show("workspace", ["agent-b"]);
(await world.nextMembership()).fail("subscription unavailable");
await world.nextError();
inFlight.respond({ hasNewer: false });
await Promise.resolve();
await Promise.resolve();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
});
test("a failed membership retry returns to error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).fail("unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
(await world.nextMembership()).fail("still unavailable");
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
});
test("redeclaring unchanged visibility does not bypass membership error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
await world.nextRetry();
await world.nextError();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setActive(false);
world.sync.setActive(true);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -1050,7 +1062,6 @@ test("backgrounding preserves subscriptions and returning verifies the visible c
world.sync.setActive(false);
world.expectNoPendingMembership();
world.elapse(60_000);
world.sync.setActive(true);
world.expectNoPendingMembership();
@@ -1067,11 +1078,10 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
world.show("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const staleRetry = await world.nextRetry();
await world.nextError();
world.show("workspace", ["agent-b"]);
const current = await world.nextMembership();
staleRetry();
current.succeed();
const agentB = await world.nextFetch("agent-b");
agentB.respond({ hasNewer: false });
@@ -1088,10 +1098,9 @@ test("membership retry cannot run while disconnected", async () => {
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const disconnectedRetry = await world.nextRetry();
await world.nextError();
world.sync.setConnected(false);
disconnectedRetry();
world.expectNoPendingMembership();
world.sync.setConnected(true);
const restored = await world.nextMembership();
@@ -1118,7 +1127,6 @@ test("returning to an open hidden agent stays live after inactivity", async () =
agentBCatchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.elapse(60_000);
world.show("workspace", ["agent-a"]);
world.expectNoPendingMembership();
@@ -329,7 +329,6 @@ export interface ViewedTimelineSyncPorts {
*/
onCatchUpEnded(agentId: string): void;
reportError(error: unknown): void;
schedule(task: () => void, delayMs: number): () => void;
}
export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying";
@@ -423,26 +422,14 @@ export function createViewedTimelineOwner(input: {
};
}
const RETRY_DELAY_MS = 1_000;
const MAX_RETRY_DELAY_MS = 30_000;
type CatchUpStatus = "running" | "complete" | "error";
interface CatchUpState {
generation: number;
status: CatchUpStatus;
request?: ProjectedTimelineForwardFetchPlan;
cancelRetry?: () => void;
retryDelayMs?: number;
}
const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => {
if (previousDelayMs == null) {
return RETRY_DELAY_MS;
}
return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS);
};
function isSameCatchUpRequest(
left: ProjectedTimelineForwardFetchPlan | undefined,
right: ProjectedTimelineForwardFetchPlan | undefined,
@@ -472,9 +459,7 @@ function decideCatchUp(input: {
}
return "replace";
}
return input.current.status === "running" || input.current.status === "complete"
? "keep"
: "replace";
return "keep";
}
function normalizeAgentIds(agentIds: string[]): string[] {
@@ -494,8 +479,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
const visibilityCatchUpPending = new Set<string>();
const visibilityCatchUpErrors = new Map<string, string>();
// User-initiated retries only. Background retries stay silent; a retry the user asked for
// owes them a pending state until it settles.
// A user-initiated retry owes a pending state until it settles.
const manualRetries = new Set<string>();
const loadedCache = new Set<string>();
const cacheLoads = new Map<string, Promise<void>>();
@@ -511,8 +495,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
let acknowledged: string[] = [];
let membershipGeneration = 0;
let membershipNeedsRetry = false;
let membershipRetryDelayMs: number | undefined;
let cancelMembershipRetry: (() => void) | null = null;
// Chats this session has opened. Visibility is the only way in, so an agent is never
// subscribed — and never resumed on the daemon — until the user actually opens it.
// Membership then survives hiding, workspace switches, backgrounding and reconnect;
@@ -563,7 +545,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const cancelCatchUp = (agentId: string) => {
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
catchUps.get(agentId)?.cancelRetry?.();
catchUps.delete(agentId);
pendingCatchUps.delete(agentId);
};
@@ -607,19 +588,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
setVisibilityCatchUpReady(agentId);
} catch (error) {
if (catchUps.get(agentId)?.generation === generation) {
const nextRetryDelayMs = getNextRetryDelayMs(catchUps.get(agentId)?.retryDelayMs);
const cancelRetry = ports.schedule(() => {
const current = catchUps.get(agentId);
if (current?.generation !== generation || current.status !== "error") return;
startCatchUp(agentId);
}, nextRetryDelayMs);
catchUps.set(agentId, {
generation,
status: "error",
request,
cancelRetry,
retryDelayMs: nextRetryDelayMs,
});
catchUps.set(agentId, { generation, status: "error", request });
setVisibilityCatchUpError([agentId], error);
ports.reportError(error);
}
@@ -686,17 +655,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (decision === "keep") {
return;
}
current?.cancelRetry?.();
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
catchUpGenerations.set(agentId, generation);
const retryDelayMs =
supersede || current?.status !== "error" ? undefined : current.retryDelayMs;
catchUps.set(agentId, {
generation,
status: "running",
request: nextRequest,
retryDelayMs,
});
catchUps.set(agentId, { generation, status: "running", request: nextRequest });
pendingCatchUps.delete(agentId);
void fetchUntilCurrent(
agentId,
@@ -715,7 +676,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
for (const agentId of ordered) {
const pendingCatchUp = pendingCatchUps.get(agentId);
const current = catchUps.get(agentId);
// Completion of a different chat must not bypass a failed chat's backoff or
// Completion of a different chat must not bypass a failed chat's error or
// supersede an in-flight tail that owns its parked gap recovery.
if (current && !(current.status === "complete" && pendingCatchUp)) continue;
startCatchUp(agentId, {
@@ -741,42 +702,31 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
} catch (error) {
if (disposed || !connected || generation !== membershipGeneration) return;
membershipNeedsRetry = true;
acknowledged = [];
for (const agentId of requested) cancelCatchUp(agentId);
setVisibilityCatchUpError(requested, error);
cancelMembershipRetry?.();
const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs);
cancelMembershipRetry = ports.schedule(() => {
cancelMembershipRetry = null;
if (disposed || !connected || membershipGeneration !== generation) return;
void reconcileMembership();
}, nextRetryDelayMs);
membershipRetryDelayMs = nextRetryDelayMs;
ports.reportError(error);
return;
}
if (disposed || !connected || generation !== membershipGeneration) return;
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
acknowledged = requested;
startAcknowledgedCatchUps();
};
const retryVisibleAgentTimeline = (agentId: string) => {
if (!isDesired(agentId) || manualRetries.has(agentId)) return;
if (!connected || !isDesired(agentId) || manualRetries.has(agentId)) return;
const catchUp = catchUps.get(agentId);
const membershipRetryable = membershipNeedsRetry && connected;
if (catchUp?.status !== "error" && !membershipRetryable) return;
manualRetries.add(agentId);
notifyListeners();
if (catchUp?.status === "error") {
catchUp.cancelRetry?.();
startCatchUp(agentId, { request: catchUp.request, supersede: true });
if (membershipRetryable) {
void reconcileMembership();
return;
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
void reconcileMembership();
if (catchUp?.status === "error") {
startCatchUp(agentId, { request: catchUp.request, supersede: true });
}
};
const commitDesiredMembership = (
@@ -817,8 +767,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
ensureCacheLoaded(agentId);
}
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
desired = nextDesired;
ports.replaceDemandedAgentIds(desired);
membershipGeneration += 1;
@@ -869,6 +817,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
return;
}
for (const agentId of visibleAgentIds()) {
if (visibilityCatchUpErrors.has(agentId)) continue;
visibilityCatchUpPending.add(agentId);
visibilityCatchUpErrors.delete(agentId);
startCatchUp(agentId, { supersede: true });
@@ -880,9 +829,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
connected = nextConnected;
if (!connected) {
commitDesiredMembership(desired, { resetCatchUpStatus: true });
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
void observation?.handle.release().catch(ports.reportError);
observation = null;
acknowledged = [];
@@ -894,7 +840,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
void reconcileMembership();
},
recoverGap(agentId, cursor) {
if (!isDesired(agentId)) return;
if (!isDesired(agentId) || catchUps.get(agentId)?.status === "error") return;
startCatchUp(agentId, {
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
supersede: true,
@@ -904,10 +850,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
disposed = true;
void observation?.handle.release().catch(ports.reportError);
observation = null;
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipNeedsRetry = false;
membershipRetryDelayMs = undefined;
sources.clear();
membershipGeneration += 1;
for (const agentId of desired) cancelCatchUp(agentId);
+10 -3
View File
@@ -21,10 +21,12 @@ export interface UsageHostSelection {
*/
export function UsageControls({
view,
serverId,
onRefresh,
hostSelection,
}: {
view: UsageView;
serverId: string;
onRefresh: () => void;
hostSelection?: UsageHostSelection;
}) {
@@ -43,8 +45,8 @@ export function UsageControls({
) : null}
{view.kind === "unavailable" ? null : (
<>
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
<UsageOptions />
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
<UsageOptions serverId={serverId} />
</>
)}
</View>
@@ -84,7 +86,12 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
const controls = useMemo(
() =>
hasControls ? (
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
<UsageControls
serverId={hostSelection.serverId}
view={view}
onRefresh={refresh}
hostSelection={hostSelection}
/>
) : null,
[hasControls, hostSelection, refresh, view],
);
-8
View File
@@ -1,7 +1,5 @@
import { useCallback, useMemo } from "react";
import { useAppSettings } from "@/hooks/use-settings";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import {
isUsagePinned,
setUsageDisplayAs,
@@ -25,12 +23,6 @@ interface UsagePreferencesState {
display: UsageDisplay;
}
/** The device's usage preferences, with pins resolved against a host's reports. */
export function useUsagePreferences(serverId?: string): UsagePreferencesState {
const defaultServerId = useUsageHostId();
return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId));
}
/** The device's usage preferences, with pins resolved against the given reports. */
export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState {
const { settings, updateSettings } = useAppSettings();
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { useUsagePreferences } from "./display";
import { useUsageDisplay } from "./display";
import { useHostUsage } from "./queries";
import { UsageSection } from "./usage-section";
@@ -7,7 +7,7 @@ import { UsageSection } from "./usage-section";
export function HostUsageSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsagePreferences(serverId);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
return (
<UsageSection
serverId={serverId}
+1 -1
View File
@@ -4,6 +4,6 @@ export { useHostReportsUsage } from "./queries";
export {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useUsageSummary,
useOpenSidebarUsage,
} from "./sidebar-item";
+12
View File
@@ -405,3 +405,15 @@ it("available and retained usage show the same successful completion time", () =
);
expect(upsertReport([failure], success)).toEqual([failure]);
});
it("disabled host shows off even with cached reports", () => {
expect(
resolveUsageView({
hostLabel: "Laptop",
isConnected: true,
supportsUsage: true,
enabled: false,
query: { data: [], error: null, isFetching: false },
}),
).toMatchObject({ kind: "off" });
});
+3
View File
@@ -106,6 +106,7 @@ export interface UsageQueryState {
export function resolveUsageView(
input: {
enabled?: boolean;
hostLabel: string;
isConnected: boolean;
supportsUsage: boolean;
@@ -125,6 +126,8 @@ export function resolveUsageView(
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
};
}
if (input.enabled === false)
return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) };
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
}
+27 -10
View File
@@ -8,15 +8,20 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { Alert } from "@/components/ui/alert";
import { useHosts } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { useUsagePreferences } from "./display";
import { useUsageSetting } from "./setting";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions() {
export function UsageOptions({ serverId }: { serverId: string | null }) {
const setting = useUsageSetting(serverId);
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
const { t } = useTranslation();
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
@@ -27,7 +32,7 @@ export function UsageOptions() {
},
];
const compact = useIsCompactFormFactor();
const { display } = useUsagePreferences();
const { display } = useUsageDisplay([]);
const { inSidebar, setInSidebar } = useUsageInSidebar();
return (
<DropdownMenu compactMode="sheet">
@@ -49,13 +54,25 @@ export function UsageOptions() {
testID="usage-options-surface"
>
<View testID="usage-options-fields">
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
{setting.supported ? (
<SettingsSwitch
label={t("providerUsage.checkUsage")}
hint={t("providerUsage.checkUsageHint", { host: hostLabel })}
disabled={!setting.ready}
value={setting.enabled}
onValueChange={setting.setEnabled}
/>
) : null}
<View style={setting.supported ? settingsStyles.rowBorder : undefined}>
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
</View>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={t("providerUsage.displayAs")}>
<SegmentedControl
+9 -3
View File
@@ -17,6 +17,7 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { useUsageSetting } from "./setting";
import { usageCopy } from "./copy";
import {
replaceReport,
@@ -132,13 +133,14 @@ function toQueryState(query: {
/** Usage reports for one host, as shown on its settings page. */
export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const query = useFetchQuery({
queryKey: usageReportsQueryKey(serverId),
queryFn: () => listReports(queryClient, serverId),
enabled: isConnected && isSupported,
enabled: isConnected && isSupported && setting.enabled,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
@@ -150,6 +152,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const view = resolveUsageView(
{
hostLabel,
enabled: setting.ready ? setting.enabled : undefined,
isConnected,
supportsUsage: isSupported,
query: toQueryState(query),
@@ -166,14 +169,16 @@ const NO_REPORTS: UsageReportEntry[] = [];
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
enabled: setting.enabled,
queryKey: usageReportsQueryKey(serverId ?? ""),
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
return query.data ?? NO_REPORTS;
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
/**
@@ -209,7 +214,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
export function useHostReportsUsage(serverId: string): boolean {
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
return isConnected && isSupported;
const setting = useUsageSetting(serverId);
return isConnected && isSupported && setting.enabled;
}
/** Every host with whether it is connected and reports usage, in host order. */
+26
View File
@@ -0,0 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback } from "react";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useSessionStore } from "@/stores/session-store";
/** Host policy comes from the live daemon config replica. */
export function useUsageSetting(serverId: string | null) {
const supported = useSessionStore((state) =>
Boolean(serverId && state.sessions[serverId]?.serverInfo?.features?.usageSetting),
);
const { config, patchConfig } = useDaemonConfig(supported ? serverId : null);
const mutation = useMutation({
mutationFn: (enabled: boolean) => patchConfig({ usage: { enabled } }),
});
const setEnabled = mutation.mutate;
const turnOn = useCallback(() => setEnabled(true), [setEnabled]);
return {
supported,
error: mutation.error,
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
enabled: !supported || (config !== null && config.usage?.enabled !== false),
ready: !supported || config !== null,
setEnabled,
turnOn,
};
}
+8 -11
View File
@@ -17,7 +17,8 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useUsagePreferences } from "./display";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import { UsageSourceIcon } from "./source-icon";
@@ -33,28 +34,24 @@ import {
import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
function useUsageSummary(): readonly PinnedUsageSource[] {
export function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const { preferences } = useUsagePreferences();
const reports = useUsageHostReports(useUsageHostId());
const { inSidebar } = useUsageInSidebar();
const serverId = useUsageHostId();
const reports = useUsageHostReports(inSidebar ? serverId : null);
const { preferences } = useUsageDisplay(reports);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
export function useHasUsageSummary(): boolean {
return useUsageSummary().length > 0;
}
/**
* The sidebar footer's usage entry: each summary window's source icon and percent, and nothing
* while no summary window has data, since the footer's Usage icon already opens Usage.
* Pressing it opens the Usage modal.
*/
export function UsageSidebarItem() {
export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) {
const { t } = useTranslation();
const label = t(builtinSidebarNavLabelKey("usage"));
const openUsage = useOpenSidebarUsage();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
}
+1
View File
@@ -10,6 +10,7 @@ export type UsageBalanceUnit = UsageBalance["unit"];
/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
export type UsageView =
| { kind: "off"; message: string }
| { kind: "unavailable"; message: string }
| { kind: "loading" }
| { kind: "error"; message: string }
+2 -2
View File
@@ -4,7 +4,7 @@ import { View } from "react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useHostUsageWithControls } from "./controls";
import { useUsagePreferences } from "./display";
import { useUsageDisplay } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageBody, UsageMessage } from "./usage-section";
@@ -52,12 +52,12 @@ function HostUsageModal({
hosts: UsageHost[];
onSelectHost: (serverId: string) => void;
}) {
const { display } = useUsagePreferences();
const hostSelection = useMemo(
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
return (
<UsageModalFrame {...props} actions={controls}>
<View testID={`usage-host-${serverId}`}>
+12 -2
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import type { UsageDisplay } from "./display";
import { useUsageSetting } from "./setting";
import { UsageControls } from "./controls";
import { UsageList } from "./list";
import type { UsageView } from "./types";
@@ -27,8 +28,8 @@ export function UsageSection({
testID?: string;
}) {
const trailing = useMemo(
() => <UsageControls view={view} onRefresh={onRefresh} />,
[onRefresh, view],
() => <UsageControls serverId={serverId} view={view} onRefresh={onRefresh} />,
[onRefresh, view, serverId],
);
return (
@@ -51,6 +52,15 @@ export function UsageBody({
onRefresh: () => void;
}) {
const { t } = useTranslation();
const setting = useUsageSetting(serverId);
if (view.kind === "off")
return (
<View>
<UsageMessage text={view.message} />
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
<Button onPress={setting.turnOn}>{t("providerUsage.turnOn")}</Button>
</View>
);
if (view.kind === "unavailable") {
return <UsageMessage text={view.message} />;
}
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
} from "./terminal-renderer-readiness";
@@ -73,6 +74,27 @@ describe("terminal-renderer-readiness", () => {
expect(current).toBeNull();
});
it("replays snapshots only for ready events from the current renderer", () => {
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: true },
terminalStreamKey: "scope:terminal-1",
}),
).toBe(true);
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: false },
terminalStreamKey: "scope:terminal-1",
}),
).toBe(false);
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: true },
terminalStreamKey: "scope:terminal-2",
}),
).toBe(false);
});
it("does not attach the stream before the matching renderer is ready", () => {
expect(
resolveTerminalStreamTarget({
@@ -14,6 +14,13 @@ export function applyTerminalRendererReadyChange(
return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey;
}
export function shouldReplayTerminalSnapshotForRenderer(input: {
change: TerminalRendererReadyChange;
terminalStreamKey: string;
}): boolean {
return input.change.isReady && input.change.streamKey === input.terminalStreamKey;
}
export function resolveTerminalStreamTarget(input: {
terminalId: string;
terminalStreamKey: string;
@@ -76,6 +76,25 @@ describe("test-daemon-connection connectToDaemon", () => {
probe = new FakeDaemonProbe();
});
it.each([
{ id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" },
{
id: "relay:test",
type: "relay" as const,
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pubkey",
},
])("adoptable $type probe clients use the app WebSocket factory", async (connection) => {
const { connectToDaemon } = await import("./test-daemon-connection");
const { createAppWebSocketFactory } = await import("@/runtime/websocket-factory");
const result = await connectToDaemon(connection, { serverId: "srv_probe_test" }, probe.deps);
try {
expect(result.client.config.webSocketFactory).toBe(createAppWebSocketFactory());
} finally {
await result.client.close();
}
});
it("reuses the app clientId for direct connections", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const first = await connectToDaemon(
@@ -6,6 +6,7 @@ import {
} from "@getpaseo/client/internal/daemon-client";
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { createAppWebSocketFactory } from "@/runtime/websocket-factory";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import {
@@ -156,6 +157,7 @@ export async function buildClientConfig(
clientType: "mobile" as const,
appVersion: deps.resolveAppVersion() ?? undefined,
suppressSendErrors: true,
webSocketFactory: createAppWebSocketFactory(),
reconnect: { enabled: false },
...resolveConnectionCredentials(connection, options),
...(options.capabilities ? { capabilities: options.capabilities } : {}),
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
return Boolean(detail.url || detail.result || detail.codeText);
}
function hasMeaningfulSubAgentDetail(
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
): boolean {
return Boolean(detail.subAgentType || detail.description || detail.log);
}
function hasMeaningfulPlainTextDetail(
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
): boolean {
@@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
case "fetch":
return hasMeaningfulFetchDetail(detail);
case "worktree_setup":
return false;
// A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
case "sub_agent":
return hasMeaningfulSubAgentDetail(detail);
return false;
case "plain_text":
return hasMeaningfulPlainTextDetail(detail);
case "plan":
@@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: {
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown;
}): boolean {
if (params.detail?.type === "worktree_setup") return false;
if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
const isRunning = params.status === "running" || params.status === "executing";
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
}
@@ -10,10 +10,11 @@
* `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): protocols 0–4
* are obsolete unreleased wires, so their programs/workers/copies are closed and a current
* worker serves new terminals. A truly newer protocol is left running, reported as
* terminal_worker_incompatible, and closed by `daemon stop`.
* 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`.
*/
@@ -34,7 +35,7 @@ import {
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "node:net";
import { getAvailablePort } from "./helpers/network.ts";
if (process.platform === "win32") {
console.log("Skipping terminal worker upgrade CLI test on Windows (POSIX shell terminals).");
@@ -49,7 +50,7 @@ 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 currentProtocolVersion = 5;
const port = await getAvailablePort();
const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
interface Installation {
@@ -335,18 +336,7 @@ async function terminalIds(installation: Installation): Promise<string[]> {
}
async function startDaemon(installation: Installation, extraEnv: Record<string, string> = {}) {
const result = await paseo(installation, ["daemon", "start", "--json"], extraEnv);
lastListen = (JSON.parse(result.stdout) as { listen: string }).listen;
assert.match(lastListen, /^127\.0\.0\.1:[1-9]\d*$/, "start publishes the actual bound port");
if (competingListener.listening) {
const competitor = competingListener.address();
assert(competitor && typeof competitor !== "string");
assert.notEqual(
Number(lastListen.split(":").at(-1)),
competitor.port,
"each daemon binds its own endpoint while the released port belongs to a peer",
);
}
await paseo(installation, ["daemon", "start"], extraEnv);
}
async function stopDaemon(installation: Installation, keepTerminals = false) {
@@ -381,14 +371,10 @@ const a = createInstallation("A", "0.98.0");
const b = createInstallation("B", "0.99.0");
let restoreA: (() => void) | null = null;
let passed = false;
let lastListen = "";
const competingListener = createServer();
try {
for (const [field, value] of [
// Let the listening daemon reserve its port atomically. Clients discover the
// actual endpoint from this home's PID record after every start.
["daemon.listen", "127.0.0.1:0"],
["daemon.listen", `127.0.0.1:${port}`],
["daemon.relay.enabled", "false"],
["features.dictation.enabled", "false"],
["features.voiceMode.enabled", "false"],
@@ -401,7 +387,6 @@ try {
const t1 = await openTerminal(a, "t1");
const [firstWorker] = workerRecords();
assert(firstWorker, "one worker runs");
assert.equal(firstWorker.protocolVersion, currentProtocolVersion, "the current wire is adopted");
assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/);
const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!);
assert(
@@ -447,62 +432,54 @@ try {
restoreA = null;
console.log("✓ closed and cleaned up\n");
// A released endpoint belongs to nobody. Reproduce another process taking it
// between starts; worker adoption is keyed by the home, not by a stable TCP port.
const releasedPort = Number(lastListen.split(":").at(-1));
await new Promise<void>((resolve, reject) => {
competingListener.once("error", reject);
competingListener.listen(releasedPort, "127.0.0.1", resolve);
});
console.log(`Competing listener owns released port ${releasedPort}`);
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");
// There is no supported previous wire at protocol 5. Future previous-version classification
// is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter.
for (const protocolVersion of [0, 1, 2, 3, 4]) {
console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`);
const obsolete = await leaveWorkerWithProtocol(
a,
protocolVersion,
`obsolete-${protocolVersion}`,
);
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");
const [current] = workerRecords();
assert.equal(workerRecords().length, 1, "one current worker runs");
assert(current);
assert.equal(current.protocolVersion, currentProtocolVersion);
assert.match(current.runtime ?? "", /^0\.99\.0-/);
await waitFor(
() => !runtimeCopies().includes(obsolete.runtime),
() => `the obsolete worker's copy ${obsolete.runtime} was not deleted`,
);
assert.deepEqual(runtimeCopies(), [current.runtime], "the current copy stays");
const replacement = await openTerminal(b, `replacement-${protocolVersion}`);
assert.deepEqual(
workerRecords()
.find((record) => record.pid === current.pid)!
.terminals.map((terminal) => terminal.id),
[replacement.id],
"new terminals open in the current worker",
);
await type(b, replacement.id, `echo after-protocol-cut-${protocolVersion}`);
await waitForScreen(b, replacement.id, `\nafter-protocol-cut-${protocolVersion}`);
await stopDaemon(b);
await waitFor(
() => !isAlive(replacement.pid) && !isAlive(current.pid),
() => "the replacement worker or program outlived daemon stop",
);
assert.deepEqual(workerRecords(), [], "no worker record is left");
assert.deepEqual(runtimeCopies(), [], "no copy is left");
console.log("✓ obsolete closed, current usable, copies cleaned up\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 5: a worker from a newer Paseo is left running and reported");
const newer = await leaveWorkerWithProtocol(a, currentProtocolVersion + 1, "newer");
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");
@@ -537,17 +514,6 @@ try {
}
}
}
if (competingListener.listening) {
await new Promise<void>((resolve, reject) =>
competingListener.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
}),
);
}
restoreA?.();
for (const installation of [b, a]) {
await run(installation, ["daemon", "stop", "--home", paseoHome, "--force"]);
+1 -1
View File
@@ -125,7 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
// Protocol support belongs to the installed client. Only browser hosting needs
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.terminalChunkedReplay]: true,
[CLIENT_CAPS.binaryChunkingV1]: true,
[CLIENT_CAPS.helloRejection]: true,
[CLIENT_CAPS.ownedSubscriptions]: true,
[CLIENT_CAPS.allProviders]: true,
@@ -1,3 +1,4 @@
import { maxSecuredPlaintextByteLength } from "@getpaseo/relay";
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
import {
openClientSecureChannel,
@@ -155,18 +156,15 @@ export function createEncryptedTransport(
const relayTransport: RelayTransport = {
send: (data) => {
if (typeof data === "string") {
base.send(data);
return;
return base.send(data);
}
if (ArrayBuffer.isView(data)) {
base.send(normalizeTransportPayload(data));
return;
return base.send(normalizeTransportPayload(data));
}
if (data instanceof ArrayBuffer) {
base.send(data);
return;
return base.send(data);
}
base.send(String(data));
return base.send(String(data));
},
// The channel decides the close code. Report it now: a socket close event
// arrives asynchronously, and the consumer must not act on an earlier
@@ -226,6 +224,8 @@ export function createEncryptedTransport(
});
return {
supportsDrain: base.supportsDrain,
maxFrameBytes: maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES),
send: (data) => {
if (!channel) {
throw new Error("Encrypted channel not ready");
@@ -239,8 +239,9 @@ export function createEncryptedTransport(
// Encryption and its transport handoff are synchronous up to send's first await.
// Reject capacity failures here so a routed sender loses only its own channel.
assertOutboundCapacity(wireBytes);
void channel.send(payload).catch((error) => {
return channel.send(payload).catch((error) => {
emitError(error);
throw error;
});
},
close: (code?: number, reason?: string) => {
@@ -1,5 +1,8 @@
export interface DaemonTransport {
send: (data: string | Uint8Array | ArrayBuffer) => void;
/** False when the underlying socket cannot report write drain. */
supportsDrain?: boolean;
maxFrameBytes?: number;
send: (data: string | Uint8Array | ArrayBuffer) => void | Promise<void>;
close: (code?: number, reason?: string) => void;
onMessage: (handler: (data: unknown, isBinary: boolean) => void) => () => void;
onOpen: (handler: () => void) => () => void;
@@ -13,12 +16,16 @@ export type DaemonTransportFactory = (options: {
protocols?: string[];
}) => DaemonTransport;
export type WebSocketFactory = (
url: string,
options?: { headers?: Record<string, string>; protocols?: string[] },
) => WebSocketLike;
export interface WebSocketFactory {
(
url: string,
options?: { headers?: Record<string, string>; protocols?: string[] },
): WebSocketLike;
supportsDrain?: boolean;
}
export interface WebSocketLike {
bufferedAmount?: number;
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
@@ -175,3 +175,18 @@ describe("daemon-client transport helpers", () => {
expect(decodeMessageData(bytes)).toBe("hello");
});
});
test("native drain capability survives WebSocket and encryption wrappers", async () => {
const { nativeWebSocketFactory } = await import("./daemon-client-websocket-transport.js");
expect(nativeWebSocketFactory.supportsDrain).toBe(false);
const factory = Object.assign(() => ({ readyState: 1, send() {}, close() {} }), {
supportsDrain: false,
});
const base = createWebSocketTransportFactory(factory)({ url: "ws://test" });
expect(base.supportsDrain).toBe(false);
const encrypted = createEncryptedTransport(base, {
daemonPublicKeyB64: "unused-key",
logger: { warn() {} },
});
expect(encrypted.supportsDrain).toBe(false);
});
@@ -39,6 +39,9 @@ export function nativeWebSocketFactory(
return new (getGlobalWebSocket())(url, options?.protocols, { headers: options?.headers });
}
// React Native leaves bufferedAmount at zero, including while native output is queued.
nativeWebSocketFactory.supportsDrain = false;
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
return ({ url, headers, protocols }) => {
const ws = factory(url, { headers, protocols });
@@ -50,11 +53,28 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
}
}
return {
supportsDrain: factory.supportsDrain !== false,
send: (data) => {
if (typeof ws.readyState === "number" && ws.readyState !== 1) {
throw new Error(`WebSocket not open (readyState=${ws.readyState})`);
}
ws.send(data);
if ((ws.bufferedAmount ?? 0) > 1024 * 1024) {
return new Promise<void>((resolve, reject) => {
const drain = () => {
if (ws.readyState !== 1) {
reject(new Error("WebSocket closed while draining"));
return;
}
if ((ws.bufferedAmount ?? 0) <= 1024 * 1024) {
resolve();
return;
}
setTimeout(drain, 10);
};
drain();
});
}
},
close: (code?: number, reason?: string) => {
// Node's `ws` may emit an `error` when a connecting socket is closed before the
+423 -213
View File
@@ -1094,6 +1094,7 @@ test("advertises client capabilities in hello", async () => {
provider_snapshot_references: true,
explicit_event_subscriptions: true,
owned_subscriptions: true,
binary_chunking_v1: true,
compact_provider_snapshots: true,
custom_mode_icons: true,
project_updates: true,
@@ -1101,7 +1102,6 @@ test("advertises client capabilities in hello", async () => {
projected_subagent_timeline: true,
reasoning_merge_enum: true,
terminal_reflowable_snapshot: true,
terminal_chunked_replay: true,
timeline_notifications: true,
plugin_timeline_items: true,
workspace_setup_blocked: true,
@@ -6388,80 +6388,66 @@ test("emits output events for the active terminal stream", async () => {
unsubscribe();
});
test.each([5, 500, 10000])(
"preserves explicit %s history demand on an old ANSI host and normalizes restore",
async (history) => {
const logger = createMockLogger();
const mock = createMockTransport();
test("emits snapshot events for the subscribed terminal stream", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const events: unknown[] = [];
const replayEvents: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type === "replay") replayEvents.push(event.replay);
events.push(event);
});
const snapshots: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type !== "snapshot") {
return;
}
snapshots.push(event.state);
});
const subscribePromise = client.subscribeTerminal("term-1", {
requestId: "sub-2",
restore: { mode: "visible-snapshot", scrollbackLines: history },
});
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({
mode: "visible-snapshot",
scrollbackLines: history,
});
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 12,
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" });
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 12,
payload: encodeTerminalSnapshotPayload(state),
}),
);
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
expect(events).toEqual([]);
const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂");
mock.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }),
);
expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]);
},
);
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: 12,
payload: encodeTerminalSnapshotPayload(state),
}),
);
expect(snapshots).toEqual([state]);
});
test("sends explicit terminal input and resize commands", async () => {
const logger = createMockLogger();
@@ -6832,91 +6818,102 @@ test("drops legacy agent_stream tool_call payloads and logs validation warning",
expect(logger.warn).toHaveBeenCalled();
});
test("parses canonical fetch_agent_timeline_response payloads without crashing", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
test.each([false, true])(
"parses canonical timeline responses with chunking=%s",
async (chunked) => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const connectPromise = client.connect();
mock.triggerOpen({ features: { ownedSubscriptions: true, binaryChunkingV1: chunked } });
await connectPromise;
const received: unknown[] = [];
const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => {
received.push(msg);
});
const received: unknown[] = [];
const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => {
received.push(msg);
});
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agent_timeline_response",
const command = chunked ? "x".repeat(50 * 1024 * 1024) : "pwd";
const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport");
const sender = new ChunkedTransport({
frameBytes: 24 * 1024 * 1024,
send: (frame) => mock.triggerMessage(frame),
deliver: () => {},
});
sender.enabled = chunked;
await sender.send(
wrapSessionMessage({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-1",
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
item: {
type: "tool_call",
callId: "call_cli_snapshot",
name: "shell",
status: "running",
detail: {
type: "shell",
command,
},
error: null,
},
},
],
error: null,
},
}),
);
unsubscribe();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
payload: {
requestId: "req-1",
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
item: {
type: "tool_call",
callId: "call_cli_snapshot",
name: "shell",
status: "running",
detail: {
type: "shell",
command: "pwd",
},
error: null,
detail: { type: "shell", command },
},
},
],
error: null,
},
}),
);
unsubscribe();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
payload: {
entries: [
{
item: {
type: "tool_call",
status: "running",
error: null,
detail: { type: "shell" },
},
},
],
},
});
expect(logger.warn).not.toHaveBeenCalled();
});
});
expect(logger.warn).not.toHaveBeenCalled();
},
);
test("rejects and logs a correlated response that violates the protocol schema", async () => {
const mock = createMockTransport();
@@ -8487,88 +8484,301 @@ test("closes before admission when a protected-capable daemon is reached in lega
expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" });
});
test("uses the frame budget selected by an asynchronously opened transport", async () => {
const mock = createMockTransport();
mock.transport.maxFrameBytes = 1024;
const client = new DaemonClient({
url: "ws://test",
clientId: "dynamic-budget",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.transport.maxFrameBytes = 16;
mock.triggerOpen({ features: { binaryChunkingV1: true } });
await connecting;
client.sendHostFrame("route", new Uint8Array(100));
await vi.waitFor(() => expect(mock.sent.length).toBeGreaterThan(1));
expect(mock.sent.every((frame) => typeof frame !== "string" && frame.byteLength <= 16)).toBe(
true,
);
});
test.each([false, true])("routed sends wait for origin drain (binary=%s)", async (binary) => {
const mock = createMockTransport();
let drain!: () => void;
let blocked = false;
const send = mock.transport.send;
mock.transport.send = (frame) => {
send(frame);
if (blocked)
return new Promise<void>((resolve) => {
drain = resolve;
});
};
const client = new DaemonClient({
url: "ws://test",
clientId: "routed-drain",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen({ features: { binaryChunkingV1: true } });
await connecting;
blocked = true;
let finished = false;
const write = Promise.resolve(
client.sendHostFrame("route", binary ? new Uint8Array([1]) : "hello"),
).then(() => {
finished = true;
return undefined;
});
await Promise.resolve();
expect(finished).toBe(false);
drain();
await write;
expect(finished).toBe(true);
});
test("a queued admission failure rejects its RPC without closing the client", async () => {
const mock = createMockTransport();
const send = mock.transport.send;
let block = false;
let drain!: () => void;
mock.transport.send = (frame) => {
if (!block) return send(frame);
if (typeof frame !== "string")
return new Promise<void>((resolve) => {
drain = resolve;
});
throw new Error("capacity");
};
const client = new DaemonClient({
url: "ws://test",
clientId: "queued-admission",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
block = true;
const first = client.sendHostFrame("route", new Uint8Array([1]));
const ping = client.ping({ timeoutMs: 1000 });
void ping.catch(() => {});
drain();
await first;
await expect(ping).rejects.toThrow("capacity");
expect(client.isConnected).toBe(true);
});
test("RPC deadlines remain active while a preceding frame waits for drain", async () => {
const mock = createMockTransport();
const send = mock.transport.send;
let blocked = false;
let drain!: () => void;
mock.transport.send = (frame) => {
if (!blocked) return send(frame);
if (typeof frame !== "string")
return new Promise<void>((resolve) => {
drain = resolve;
});
};
const client = new DaemonClient({
url: "ws://test",
clientId: "drain-deadline",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
blocked = true;
const first = client.sendHostFrame("route", new Uint8Array([1]));
try {
await expect(client.ping({ timeoutMs: 20 })).rejects.toThrow("Timeout waiting for message");
expect(client.isConnected).toBe(true);
} finally {
drain();
await first;
}
});
test.each([false, true])(
"default terminal observation requests ANSI replay (chunked=%s)",
"event listener errors do not close the transport (chunked=%s)",
async (chunked) => {
const transport = createMockTransport();
const mock = createMockTransport();
const closed = vi.fn();
mock.transport.close = closed;
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
clientId: "listener-error",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({
features: { ownedSubscriptions: true, "terminal-restore-modes": true },
});
mock.triggerOpen({ features: { binaryChunkingV1: chunked } });
await connecting;
const events: import("./terminal-stream-router").TerminalStreamEvent[] = [];
const observation = client.observeTerminal("default-terminal", (event) => events.push(event));
await vi.waitFor(() =>
expect(parseSentFrame(transport.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
const request = parseSentFrame(transport.sent.at(-1));
expect(request.restore).toEqual({ mode: "full-snapshot" });
transport.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "default-terminal",
requestId: request.requestId,
subscriptionId: "default-subscription",
slot: 2,
error: null,
},
}),
);
await observation.ready;
const data = new TextEncoder().encode("\x1b[31mretained 界🙂\x1b[0m");
const metadata = { rows: 24, cols: 80, retainedHistoryLines: 1000 };
if (chunked) {
transport.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayBegin,
slot: 2,
payload: JSON.stringify(metadata),
}),
);
transport.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 2,
payload: data,
}),
);
transport.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.ReplayEnd, slot: 2 }),
);
} else {
// An ANSI-era host selects its existing Restore path for this explicit request.
transport.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 2, payload: data }),
);
}
expect(events).toEqual([
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: chunked ? { type: "begin", ...metadata } : { type: "begin" },
},
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: { type: "data", data },
},
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: { type: "end" },
},
]);
const unsubscribe = client.subscribe(() => {
throw new Error("listener failed");
});
const text = wrapSessionMessage({
type: "agent_deleted",
payload: { agentId: "agent-test", requestId: "listener-test" },
});
const payload = new TextEncoder().encode(text);
const frame = chunked ? new Uint8Array([0x31, 1, 0, 3, ...payload]) : text;
expect(() => mock.triggerMessage(frame)).toThrow("listener failed");
expect(closed).not.toHaveBeenCalled();
unsubscribe();
const events: string[] = [];
client.subscribe((event) => {
events.push(event.type);
});
mock.triggerMessage(frame);
expect(events).toEqual(["agent_deleted"]);
await client.sendHostFrame("route", "still connected");
expect(client.isConnected).toBe(true);
},
);
test("malformed chunk metadata still closes the client transport", async () => {
const mock = createMockTransport();
const closed = vi.fn();
mock.transport.close = closed;
const client = new DaemonClient({
url: "ws://test",
clientId: "malformed-chunk",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen({ features: { binaryChunkingV1: true } });
await connecting;
mock.triggerMessage(new Uint8Array([0x31, 2, 0, 3, 65]));
expect(closed).toHaveBeenCalledWith(4003, "Invalid chunked transport");
});
test.each([FileTransferOpcode.FileBegin, FileTransferOpcode.FileChunk, FileTransferOpcode.FileEnd])(
"upload stops at a queued pre-write rejection (opcode=%s)",
async (opcode) => {
const mock = createMockTransport();
const send = mock.transport.send;
let drain!: () => void;
let blocked = false;
const attempts: number[] = [];
mock.transport.send = (frame) => {
if (typeof frame !== "string") {
const decoded = decodeFileTransferFrame(assertUint8Array(frame));
if (decoded) {
attempts.push(decoded.opcode);
if (decoded.opcode === opcode) throw new Error("upload capacity");
} else if (blocked)
return new Promise<void>((resolve) => {
drain = resolve;
});
}
return send(frame);
};
const client = new DaemonClient({
url: "ws://test",
clientId: "upload-admission",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
blocked = true;
const first = client.sendHostFrame("route", new Uint8Array([1]));
const upload = client.uploadFile({
fileName: "file.bin",
mimeType: "application/octet-stream",
bytes: new Uint8Array([1, 2]),
chunkSize: 1,
});
let rejected: unknown;
void upload.catch((error) => {
rejected = error;
});
await new Promise<void>((resolve) => setTimeout(resolve, 0));
drain();
await first;
await vi.waitFor(() => expect(rejected).toMatchObject({ message: "upload capacity" }), {
timeout: 500,
});
expect(attempts.at(-1)).toBe(opcode);
expect(client.isConnected).toBe(true);
},
);
test.each(["latency", "dictation start", "dictation finish"])(
"queued control send rejection reaches its caller (%s)",
async (operation) => {
const mock = createMockTransport();
const send = mock.transport.send;
let blocked = false;
let drain!: () => void;
mock.transport.send = (frame) => {
if (!blocked) return send(frame);
if (typeof frame !== "string")
return new Promise<void>((resolve) => {
drain = resolve;
});
throw new Error("control capacity");
};
const client = new DaemonClient({
url: "ws://test",
clientId: "control-admission",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
blocked = true;
const first = client.sendHostFrame("route", new Uint8Array([1]));
let result: Promise<unknown>;
if (operation === "latency") result = client.measureLatency({ timeoutMs: 1000 });
else if (operation === "dictation start")
result = client.startDictationStream("dictation", "pcm");
else result = client.finishDictationStream("dictation", 0);
const rejected = expect(result).rejects.toThrow("control capacity");
drain();
await first;
await rejected;
expect(client.isConnected).toBe(true);
},
);
test("routed channels inherit an origin without drain support", async () => {
const { createHostChannelTransport } = await import("./hosts/index.js");
const mock = createMockTransport();
mock.transport.supportsDrain = false;
const client = new DaemonClient({
url: "ws://test",
clientId: "unpaced-route",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
const route = createHostChannelTransport(
client,
"srv_destination",
"scope",
"route",
)({ url: "ws://route" });
expect(route.supportsDrain).toBe(false);
route.close();
});
+106 -64
View File
@@ -1,3 +1,7 @@
import {
ChunkedTransport,
ChunkedTransportProtocolError,
} from "@getpaseo/protocol/chunked-transport";
import {
HOST_ROUTE_OPCODE,
encodeHostRouteFrame,
@@ -1266,7 +1270,7 @@ function toReasonCode(reason: string | null | undefined): string | null {
}
interface PendingSend {
send: () => void;
send: () => void | Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
timeoutHandle: ReturnType<typeof setTimeout>;
@@ -1366,6 +1370,7 @@ export class DaemonClient {
failed: (error) => this.logger.error({ err: error }, "Subscription failed"),
});
private transport: DaemonTransport | null = null;
private chunkedTransport: ChunkedTransport | null = null;
private helloAuth: HelloAuth;
private transportCleanup: Array<() => void> = [];
private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set();
@@ -1560,6 +1565,17 @@ export class DaemonClient {
});
this.traceConnectionAttempt += 1;
this.transport = transport;
this.chunkedTransport = new ChunkedTransport({
get supportsDrain() {
return transport.supportsDrain;
},
get frameBytes() {
return transport.maxFrameBytes ?? 32 * 1024 * 1024;
},
send: (frame) => transport.send(frame),
onFailure: () => transport.close(4003, "Transport send failed"),
deliver: (frame) => this.handleTransportMessage(frame),
});
this.lastServerInfoMessage = null;
this.authFailureReasonValue = null;
@@ -1666,7 +1682,16 @@ export class DaemonClient {
});
}),
transport.onMessage((data) => {
if (this.transport === transport) this.handleTransportMessage(data);
if (this.transport !== transport) return;
const bytes = asUint8Array(data);
try {
if (typeof data === "string" || bytes)
this.chunkedTransport!.receive(typeof data === "string" ? data : bytes!);
else this.handleTransportMessage(data);
} catch (error) {
if (!(error instanceof ChunkedTransportProtocolError)) throw error;
transport.close(4003, "Invalid chunked transport");
}
}),
];
} catch (error) {
@@ -1867,9 +1892,9 @@ export class DaemonClient {
})
).routeId;
}
sendHostFrame(routeId: string, data: string | Uint8Array): void {
sendHostFrame(routeId: string, data: string | Uint8Array): Promise<void> {
if (typeof data === "string") {
this.sendSessionMessageStrict({
return this.sendSessionMessageStrict({
type: "hosts.route.frame.request",
routeId,
data,
@@ -1877,7 +1902,7 @@ export class DaemonClient {
});
} else {
if (!this.isConnected || !this.transport) throw this.unavailableError();
this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
return this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
}
}
private readonly hostFrameListeners = new Set<(frame: HostRouteFrame) => void>();
@@ -1935,6 +1960,11 @@ export class DaemonClient {
// Message Subscription
// ============================================================================
/** Drain capability inherited by transports routed through this connection. */
get supportsDrain(): boolean {
return this.transport?.supportsDrain !== false;
}
subscribe(handler: DaemonEventHandler): () => void {
this.eventListeners.add(handler);
return () => {
@@ -2043,7 +2073,7 @@ export class DaemonClient {
messageType: string,
message: unknown,
submitted?: () => void,
): void {
): Promise<void> {
const isOpen =
this.config.trace?.isEnabled() === true &&
this.beginTraceSection("paseo.ws.message.outbound", {
@@ -2052,25 +2082,30 @@ export class DaemonClient {
...describeRequestIdentity(message),
});
try {
this.sendTransportFrame(JSON.stringify(message), submitted);
return this.sendTransportFrame(JSON.stringify(message), submitted);
} finally {
this.endTraceSection(isOpen);
}
}
private sendSessionPayload(payload: SessionInboundMessage): void {
this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => {
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
for (const waiter of this.waiters) {
if (waiter.requestId === payload.requestId) waiter.submitted = true;
}
});
private sendSessionPayload(payload: SessionInboundMessage): Promise<void> {
return this.sendJsonMessage(
"session",
payload.type,
{ type: "session", message: payload },
() => {
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
for (const waiter of this.waiters) {
if (waiter.requestId === payload.requestId) waiter.submitted = true;
}
},
);
}
private sendTransportFrame(
frame: string | Uint8Array | ArrayBuffer,
submitted?: () => void,
): void {
): Promise<void> {
if (!this.transport) {
throw this.unavailableError();
}
@@ -2083,7 +2118,8 @@ export class DaemonClient {
});
try {
submitted?.();
this.transport.send(frame);
const payload = typeof frame === "string" ? frame : asUint8Array(frame)!;
return this.chunkedTransport!.send(payload);
} finally {
this.endTraceSection(isOpen);
}
@@ -2112,10 +2148,10 @@ export class DaemonClient {
}
}
private sendBinaryFrame(frame: Uint8Array): void {
private sendBinaryFrame(frame: Uint8Array): Promise<void> {
if (!this.transport || this.connectionState.status !== "connected") {
if (this.config.suppressSendErrors) {
return;
return Promise.resolve();
}
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
}
@@ -2124,10 +2160,10 @@ export class DaemonClient {
envelopeType: "binary",
messageType: "binary",
});
this.sendTransportFrame(frame);
return this.sendTransportFrame(frame);
} catch (error) {
if (this.config.suppressSendErrors) {
return;
return Promise.resolve();
}
throw error instanceof Error ? error : new Error(String(error));
}
@@ -2142,7 +2178,7 @@ export class DaemonClient {
private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise<void> {
return this.sendWhenConnected(() => {
const payload = SessionInboundMessageSchema.parse(message);
this.sendSessionPayload(payload);
return this.sendSessionPayload(payload);
});
}
@@ -2151,13 +2187,12 @@ export class DaemonClient {
return this.sendWhenConnected(() => undefined);
}
private sendWhenConnected(send: () => void): Promise<void> {
private sendWhenConnected(send: () => void | Promise<void>): Promise<void> {
const status = this.connectionState.status;
// If connected, send immediately
if (this.transport && status === "connected") {
send();
return Promise.resolve();
return Promise.resolve(send());
}
// If connecting, queue the message to be sent once connected
@@ -2196,8 +2231,7 @@ export class DaemonClient {
clearTimeout(pending.timeoutHandle);
try {
if (this.transport && this.connectionState.status === "connected") {
pending.send();
pending.resolve();
void Promise.resolve(pending.send()).then(pending.resolve, pending.reject);
} else {
pending.reject(new DaemonConnectionError("Connection lost before message could be sent"));
}
@@ -2253,19 +2287,17 @@ export class DaemonClient {
{ ...params.options, requestId: params.requestId },
);
// The response deadline still applies while the transport waits for drain.
void promise.catch(() => undefined);
try {
await this.sendSessionMessageOrThrow(wire.message);
const sent = this.sendSessionMessageOrThrow(wire.message);
const result = await Promise.race([promise, sent.then(() => promise)]);
if (result.kind === "error") throw result.error;
return result.value;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
cancel(err);
void promise.catch(() => undefined);
throw err;
}
try {
const result = await promise;
if (result.kind === "error") throw result.error;
return result.value;
} finally {
await wire.finish();
}
@@ -2355,13 +2387,13 @@ export class DaemonClient {
});
}
private sendSessionMessageStrict(message: SessionInboundMessage): void {
private sendSessionMessageStrict(message: SessionInboundMessage): Promise<void> {
if (!this.transport || this.connectionState.status !== "connected") {
throw this.unavailableError();
}
const payload = SessionInboundMessageSchema.parse(message);
try {
this.sendSessionPayload(payload);
return this.sendSessionPayload(payload);
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
@@ -2559,15 +2591,16 @@ export class DaemonClient {
};
this.pingProbe = probe;
try {
this.sendJsonMessage("ping", "ping", { type: "ping" });
} catch (error) {
this.clearPingProbe();
const rejectSend = (error: unknown) => {
if (this.pingProbe !== probe) return;
const sendError = error instanceof Error ? error : new Error(String(error));
if (probe.drivesLivenessFailure) {
this.recordLivenessFailure(sendError);
}
return Promise.reject(sendError);
this.rejectPingProbe(sendError);
if (probe.drivesLivenessFailure) this.recordLivenessFailure(sendError);
};
try {
void this.sendJsonMessage("ping", "ping", { type: "ping" }).catch(rejectSend);
} catch (error) {
rejectSend(error);
}
return promise;
@@ -4222,7 +4255,7 @@ export class DaemonClient {
}
async sendVoiceAudioChunk(audio: string, format: string, isLast = false): Promise<void> {
this.sendSessionMessage({ type: "voice_audio_chunk", audio, format, isLast });
return this.sendSessionMessageStrict({ type: "voice_audio_chunk", audio, format, isLast });
}
async startDictationStream(dictationId: string, format: string): Promise<void> {
@@ -4263,8 +4296,13 @@ export class DaemonClient {
const cleanupError = new Error("Cancelled dictation start waiter");
try {
this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format });
await Promise.race([ackPromise, errorPromise]);
const sent = this.sendSessionMessageStrict({
type: "dictation_stream_start",
dictationId,
format,
});
const response = Promise.race([ackPromise, errorPromise]);
await Promise.race([response, sent.then(() => response)]);
} finally {
ack.cancel(cleanupError);
streamError.cancel(cleanupError);
@@ -4399,12 +4437,17 @@ export class DaemonClient {
const cleanupError = new Error("Cancelled dictation finish waiter");
try {
this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq });
const firstOutcome = await Promise.race([
const sent = this.sendSessionMessageStrict({
type: "dictation_stream_finish",
dictationId,
finalSeq,
});
const response = Promise.race([
finalOutcomePromise,
errorOutcomePromise,
finishAcceptedOutcomePromise,
]);
const firstOutcome = await Promise.race([response, sent.then(() => response)]);
if (firstOutcome.kind === "final") {
return firstOutcome.payload;
@@ -5320,8 +5363,10 @@ export class DaemonClient {
return undefined;
},
);
const sendFrame = (frame: Uint8Array) =>
Promise.race([this.sendBinaryFrame(frame), responsePromise]);
try {
this.sendBinaryFrame(
await sendFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: resolvedRequestId,
@@ -5335,6 +5380,7 @@ export class DaemonClient {
}),
);
if (settled) return await responsePromise;
const chunkSize = input.chunkSize ?? 128 * 1024;
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
// Native WebSocket.send encodes binary synchronously. Let rendering and
@@ -5344,7 +5390,7 @@ export class DaemonClient {
if (this.transport !== uploadTransport || this.connectionState.status !== "connected") {
throw new DaemonConnectionError("Connection changed during file upload");
}
this.sendBinaryFrame(
await sendFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: resolvedRequestId,
@@ -5353,7 +5399,7 @@ export class DaemonClient {
);
}
this.sendBinaryFrame(
await sendFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId: resolvedRequestId,
@@ -6414,8 +6460,7 @@ export class DaemonClient {
}
/**
* Defaults to a full ANSI replay. Replay begin/data/end and live output arrive in receive.
* Subscribe to the returned handle
* Snapshot/restore and output arrive in receive. Subscribe to the returned handle
* for terminal_stream_exit: payload.error means observation failure, not PTY exit.
* Either outcome detaches this slot and releases the handle automatically.
*/
@@ -6434,7 +6479,7 @@ export class DaemonClient {
{
type: "subscribe_terminal_request",
terminalId,
restore: options?.restore ?? { mode: "full-snapshot" },
...(options?.restore ? { restore: options.restore } : {}),
},
options,
);
@@ -6764,6 +6809,8 @@ export class DaemonClient {
}
private disposeTransport(code = 1001, reason = "Reconnecting"): void {
this.chunkedTransport?.close();
this.chunkedTransport = null;
this.owned.disconnected();
this.providerSnapshotUpdates.clear();
this.stopLivenessHeartbeat();
@@ -7233,15 +7280,6 @@ export class DaemonClient {
probe.resolve(perfNow() - probe.startedAt);
}
private clearPingProbe(): void {
const probe = this.pingProbe;
if (!probe) {
return;
}
this.pingProbe = null;
clearTimeout(probe.timeoutHandle);
}
private rejectPingProbe(error: Error): void {
const probe = this.pingProbe;
if (!probe) {
@@ -7290,6 +7328,10 @@ export class DaemonClient {
if (consumerMessage.type === "status") {
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
if (serverInfo) {
if (this.chunkedTransport)
this.chunkedTransport.enabled =
serverInfo.features?.binaryChunkingV1 === true &&
this.config.capabilities?.[CLIENT_CAPS.binaryChunkingV1] !== false;
this.lastServerInfoMessage = serverInfo;
this.destinationServerId = serverInfo.serverId;
this.connectionFailure = undefined;
+6 -1
View File
@@ -1,3 +1,4 @@
import { MAX_HOST_CHANNEL_FRAME_BYTES } from "@getpaseo/protocol/binary-frames/index";
import { safeRandomId } from "../daemon-client-transport-utils.js";
import { DaemonClient } from "../daemon-client.js";
import type { DaemonTransportFactory } from "../daemon-client-transport-types.js";
@@ -84,13 +85,17 @@ export function createHostChannelTransport(
})
.catch(close);
return {
get supportsDrain() {
return origin.supportsDrain;
},
maxFrameBytes: MAX_HOST_CHANNEL_FRAME_BYTES,
send(data) {
if (closed || !routeId)
throw Object.assign(new Error(`Host ${serverId} is offline`), {
code: "host_offline",
serverId,
});
origin.sendHostFrame(
return origin.sendHostFrame(
routeId,
typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data),
);
-1
View File
@@ -1,4 +1,3 @@
export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
export type { AgentAddress } from "@getpaseo/protocol/addresses";
+12 -4
View File
@@ -44,12 +44,14 @@ test("a slow consumer cannot exceed the shared byte budget and close releases re
socket.pause();
await ready;
const frame = new Uint8Array(1024 * 1024);
for (let index = 0; index < 4; index++) transport.send(frame);
const writes = Array.from({ length: 4 }, () => transport.send(frame));
const drained = Promise.allSettled(writes);
expect(budget.bufferedBytes).toBe(4 * 1024 * 1024);
expect(() => transport.send(frame)).toThrow("Connected host buffer limit reached");
expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit);
transport.close();
await expect.poll(() => budget.bufferedBytes).toBe(0);
await drained;
});
test("direct connector preserves 25 MiB binary frames in both directions", async () => {
const server = await endpoint();
@@ -65,12 +67,13 @@ test("direct connector preserves 25 MiB binary frames in both directions", async
await ready;
const bytes = new Uint8Array(25 * 1024 * 1024).fill(173);
const echoed = once(socket, "message");
transport.send(bytes);
const sending = transport.send(bytes);
const [sent, binary] = await echoed;
expect(binary).toBe(true);
expect(Buffer.from(sent as Uint8Array).equals(bytes)).toBe(true);
socket.send(bytes);
expect(Buffer.from(await received).equals(bytes)).toBe(true);
await sending;
});
import {
@@ -124,24 +127,29 @@ test("encrypted connector rejects overload synchronously and preserves the physi
});
await new Promise<void>((resolve) => transport.onOpen(resolve));
await ready;
expect(transport.maxFrameBytes).toBeLessThan(32 * 1024 * 1024);
peer.pause();
const frame = new Uint8Array(1024 * 1024).fill(173);
let rejected: unknown;
const writes: (void | Promise<void>)[] = [];
for (let index = 0; index < 8; index++) {
try {
transport.send(frame);
writes.push(transport.send(frame));
} catch (error) {
rejected = error;
break;
}
}
const drained = Promise.allSettled(writes);
expect(rejected).toMatchObject({ code: "resource_limit" });
expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(errors).toEqual([]);
expect(closed).toBe(false);
transport.send("sibling control");
const sibling = transport.send("sibling control");
peer.resume();
await drained;
await sibling;
await expect.poll(() => received.includes("sibling control")).toBe(true);
expect(received.filter((data) => data instanceof ArrayBuffer).length).toBeGreaterThan(0);
expect(
+21 -7
View File
@@ -2,6 +2,7 @@ export { createHostChannelTransport } from "../hosts/index.js";
import { HostTransportBudget } from "./buffering.js";
export { HostTransportBudget } from "./buffering.js";
import { homedir } from "node:os";
import type { Agent } from "node:http";
import { WebSocket } from "ws";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
@@ -19,10 +20,15 @@ export { parseHostTarget };
export type { SshTunnel } from "./ssh-tunnel.js";
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
export interface HostTransportOptions {
webSocketAgent?: (url: string) => Agent | undefined;
}
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
export function createHostTransportFactory(
connection: string,
budget = new HostTransportBudget(),
options: HostTransportOptions = {},
): DaemonTransportFactory {
const target = parseHostTarget(connection);
// Rises to "protected" once the daemon negotiates it, so a later attempt
@@ -68,12 +74,13 @@ export function createHostTransportFactory(
return;
}
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
let factory = createWebSocketTransportFactory((url, options) => {
let factory = createWebSocketTransportFactory((url, socketOptions) => {
const socket = new WebSocket(
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
options?.protocols,
socketOptions?.protocols,
{
headers: options?.headers,
headers: socketOptions?.headers,
agent: socketPath ? undefined : options.webSocketAgent?.(url),
...(socketPath ? { socketPath } : {}),
maxPayload: 100 * 1024 * 1024,
},
@@ -165,9 +172,15 @@ export function createHostTransportFactory(
close();
});
return {
get supportsDrain() {
return transport?.supportsDrain;
},
get maxFrameBytes() {
return transport?.maxFrameBytes;
},
send(data) {
if (closed || !transport) throw new Error("Host transport is unavailable");
transport.send(data);
return transport.send(data);
},
close,
onOpen(handler) {
@@ -191,14 +204,15 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, ...clientConfig } = config;
return new DaemonClient({
...config,
...clientConfig,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
});
}
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
describe("terminal-stream-router", () => {
test("normalizes old ANSI restore into the replay lifecycle", () => {
test("routes restore frames as restore events", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const payload = new TextEncoder().encode("restored screen");
@@ -16,14 +16,14 @@ describe("terminal-stream-router", () => {
payload,
});
expect(events).toEqual(
["begin", "data", "end"].map((type) => ({
expect(events).toEqual([
{
terminalId: "term-1",
subscriptionId: "subscription-1",
type: "replay",
replay: type === "data" ? { type, data: payload } : { type },
})),
);
type: "restore",
data: payload,
},
]);
});
});
@@ -61,45 +61,3 @@ test("legacy listeners share a terminal slot without releasing each other", () =
router.handleFrame(frame);
expect(received).toEqual(["first", "second", "second"]);
});
test("streams chunks without joining and ignores released slots during replay", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const release = router.attach("old", "terminal", 4, (event) => events.push(event));
const payload = new TextEncoder().encode(
JSON.stringify({ rows: 24, cols: 80, retainedHistoryLines: 1000 }),
);
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 4, payload });
const data = new TextEncoder().encode("\x1b[31m界🙂");
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 4,
payload: data.subarray(0, 7),
});
release();
router.attach("new", "terminal", 5, (event) => events.push(event));
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 4,
payload: data.subarray(7),
});
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayEnd,
slot: 4,
payload: new Uint8Array(),
});
expect(events).toHaveLength(2);
expect(events[1]).toMatchObject({
subscriptionId: "old",
type: "replay",
replay: { type: "data", data: data.subarray(0, 7) },
});
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 5, payload });
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayData, slot: 5, payload: data });
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayEnd,
slot: 5,
payload: new Uint8Array(),
});
expect(events.slice(2).map((event) => event.subscriptionId)).toEqual(["new", "new", "new"]);
});

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