Compare commits

...
Author SHA1 Message Date
Mohamed Boudra d1278aa856 Update tests for steering capability and send-path routing
Test drift surfaced by rebasing onto main:

- client SDK agent fixture now expects supportsSteering: false (the steering feature adds this capability flag, schema-defaulted).

- permission-response fake AgentManager gains startAgentRun, since the shared startAgentRun helper now routes through AgentManager.startAgentRun (the steer-vs-replace-vs-stream dispatch) instead of calling streamAgent/replaceAgentRun directly.
2026-05-31 10:27:13 +07:00
Mohamed Boudra 091350544d Fix finish notification steering dispatch 2026-05-31 10:13:31 +07:00
Mohamed Boudra bc825af843 Add steering follow-up support 2026-05-31 10:13:31 +07:00
Mohamed Boudra 41cb1af036 Add handle pan-down fallback to bottom-sheet reopen e2e
The "model selector can open, close, reopen, and close again" test still flaked
on loaded CI runners even after re-tapping the backdrop: the model-selector
sheet re-renders as its model list settles, and Gorhom drops backdrop presses
during that churn, so the tap never dismisses within the retry window.

Fall back to dragging the sheet handle down, which drives Gorhom's pan-to-close
directly and is unaffected by the backdrop churn (mirrors the resilient close in
e2e/helpers/app.ts). The backdrop tap remains the primary path, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged. Verified locally by forcing the fallback (backdrop tap
disabled): the handle drag closes the sheet on its own.
2026-05-31 08:38:46 +07:00
Mohamed Boudra 17aa957d3e Stabilize bottom-sheet reopen e2e by retrying the backdrop tap (#1238)
The "model selector can open, close, reopen, and close again" test fails on
loaded CI runners: a single backdrop tap that lands while the sheet is still
present-animating is dropped (Gorhom ignores backdrop presses until the sheet
settles at its snap point), so the sheet never dismisses and the backdrop stays
visible. The reopen behaviour itself is correct — the visibility tracker is
unit-tested and the spec passes locally (3x), so this is test-interaction
fragility, not a product regression.

Re-tap the backdrop until the sheet dismisses, mirroring the resilient close
loop already used in e2e/helpers/app.ts. It stays a pure backdrop dismissal (no
Escape/pan fallback) so the real close path is still exercised, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged.
2026-05-30 22:59:02 +08:00
paseo-ai[bot] 2c756e9a8a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-30 02:53:33 +00:00
Mohamed Boudra bb6a4db6e0 chore(release): cut 0.1.87 2026-05-30 09:49:45 +07:00
Mohamed Boudra 2a13a082b7 Changelog for 0.1.87 2026-05-30 09:48:43 +07:00
Mohamed Boudra eea5932a21 Fix mobile bottom sheets reopening after dismiss 2026-05-30 09:43:49 +07:00
paseo-ai[bot] e04bb942e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 16:05:39 +00:00
Mohamed Boudra 3c3574d670 Fix intermittent Android crash by upgrading Reanimated to 4.3.1
Reanimated 4.1.x crashes on Android with a NullPointerException in
ViewGroup.dispatchDraw (ReactViewGroup.dispatchDraw) — a child view is
null mid-draw because Reanimated commits a ShadowTree change during the
Android draw pass, mutating the view hierarchy while it's being painted.
Fixed upstream in 4.3.0 (software-mansion/react-native-reanimated#9072).

4.3.x requires react-native-worklets 0.8.x, so worklets moves 0.5.1 ->
0.8.3 in lockstep. react-native is pinned to exact 0.81.5 so the
re-resolve can't pull 0.81.6 (which needs react ^19.1.4, conflicting with
the react 19.1.0 override), and reanimated/worklets are added to root
overrides to force a single version across the tree — two copies of the
worklets runtime throw a version-mismatch at startup.
2026-05-29 23:01:07 +07:00
Mohamed Boudra 44863ec1dd Forward subagent permission requests 2026-05-29 22:02:18 +07:00
Mohamed Boudra 47414abc5e website tweaks 2026-05-29 19:33:57 +07:00
paseo-ai[bot] 9860dd36ef fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 11:50:20 +00:00
46 changed files with 2047 additions and 136 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 0.1.87 - 2026-05-30
### Added
- Permission prompts from OpenCode subagents now surface in Paseo so you can approve or deny them
### Fixed
- Fixed an intermittent Android crash while animated views were drawing
- Fixed mobile bottom sheets not reopening after being dismissed
## 0.1.86 - 2026-05-29
### Added
+14
View File
@@ -155,6 +155,20 @@ The "render invisible to measure, then reveal" pattern is the canonical
solution to chicken-and-egg positioning in this codebase. Reach for it before
anything fancier.
## Gotcha 6 — Bottom sheet refs are not lifecycle truth
`@gorhom/bottom-sheet` modals churn their imperative ref while presenting and
dismissing. Do not treat `ref != null` as permission to call `present()`, and do
not treat `ref == null` as the sheet being closed. The user-visible lifecycle is
the desired `visible` prop plus the sheet callbacks (`onChange(-1)`,
`onDismiss`).
If a user closes a sheet with the backdrop or a pan gesture, the sheet may detach
and reattach before React state has acknowledged `visible=false`. Re-presenting
on that attach races Gorhom's dismiss path and leaves the modal unable to reopen.
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
ignore ref churn while dismissing.
## Recipe for a new anchored panel
Before you write a new one, ask:
@@ -0,0 +1,149 @@
# Session God-File Decomposition Plan
`packages/server/src/server/session.ts` — 9116 lines, one `Session` class (declared line 724), 128 handlers, ~60 instance fields, 7 entangled domains. Goal: a **strictly behavior-preserving, incremental** decomposition into per-domain controllers, mirroring the existing `TerminalSessionController`.
## Chosen strategy: controller-context (per-domain option-bag controllers)
Each domain becomes a controller class in its own file with the **exact** contract the repo already proved twice (`TerminalSessionController` at `packages/server/src/terminal/terminal-session-controller.ts`, and `CreateAgentLifecycleDispatch`):
- An **options-bag constructor** injecting only what that domain reads.
- An **owned-type `ReadonlySet`** of message types.
- A **NON-async `dispatch(msg): Promise<void> | undefined`** that checks the owned-type set FIRST and returns `undefined` synchronously on a miss (verified at terminal-session-controller.ts:140-143).
- `start()` wired from `subscribeToOptionalManagers`, `dispose()` called by the shell's ordered `cleanup()`.
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
| # | Slice | Effort | Risk |
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
| 5 | WorkspaceController | XL | high |
| 6 | Voice prereqs: emit() purity + abortController ownership | M | high |
| 7 | VoiceSessionController | XL | high |
| 8a | Agent-lifecycle config setters | M | medium |
| 8b | AgentLifecycleController | XL | high |
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
---
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `providerSnapshotManager` (**shared by reference** — stays a daemon singleton read by checkout/lifecycle/workspace), `isProviderVisibleToClient` (predicate closing over `this`, reads `appVersion` live), `downgradeModeIconsForClient`, `downgradeEntryModesForClient`, agent-control reads `{ listProviderAvailability, listDraftFeatures }`.
**Behavior note:** COMPAT correctness — PUSH and PULL paths MUST call the SAME injected visibility/downgrade closures, reading `appVersion` LIVE (mutated post-construction via `updateAppVersion`). Keep `COMPAT(providersSnapshot)` and `COMPAT(customModeIcons)` comments verbatim. Do NOT pull `resolveStructuredGenerationProviders`/`getFocusedAgentSelectionForCwd` in. **Tests:** `session.dispatch-seam.test.ts`, `daemon-e2e/models.e2e.test.ts`, `session.test.ts`.
---
## Slice 3 — Split the shared observer seams (prerequisite, no controller)
In-place refactor on the shell, two named fan-outs:
1. **workspace-git observer** (4910-4917): make `emitCheckoutStatusUpdate` and `onBranchChanged` injectable callbacks; keep `workspaceGitWatchTargets`/`workspaceGitSubscriptions` shared by reference.
2. **agentManager.subscribe callback** (~1298): refactor into `{ onAgentUpdate, shouldAutoAllowVoicePermission(event), onStreamEvent }`.
**Behavior note:** the single hardest seam, split exactly once before the two domains that co-own it. The observer fires BOTH workspace (`handleWorkspaceGitBranchSnapshot`, `emitWorkspaceUpdateForCwd`) and checkout (`emitCheckoutStatusUpdate`) effects; the agent-subscribe callback is invoked by agent EVENTS (not the `??` chain) and does lifecycle + voice work. Add a test asserting BOTH a `workspace_update` and a `checkout_status_update` fire from one simulated git snapshot change, and a voice-permission test for the auto-allow path. **Tests:** `session.workspace-git-watch.test.ts`, `session.workspaces.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 4 — GitCheckoutController
**Move:** ~22 `checkout_*`/`stash_*`/PR/github handlers + `handleSubscribeCheckoutDiffRequest`/`handleUnsubscribeCheckoutDiffRequest` + `emitCheckoutStatusUpdate` + `checkoutDiffSubscriptions` → `packages/server/src/server/checkout/git-checkout-controller.ts`. Collapse `dispatchCheckoutMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `checkoutDiffManager` (move in + dispose teardown), `github` (shared), `workspaceGitService` (**shared spine**), `workspaceGitWatchTargets`/`workspaceGitSubscriptions` (**shared**), `providerSnapshotManager.listRegisteredProviderIds`. `emitCheckoutStatusUpdate` is now owned here and injected back into the workspace observer seam from Slice 3.
**Behavior note:** safe now that Slice 3 split the observer. `checkoutDiffSubscriptions` teardown moves to `dispose()`, called by `cleanup()` at its current ordinal (8530). **Tests:** `session.dispatch-seam.test.ts`, `checkout-diff-manager.test.ts`, `daemon-e2e/checkout-diff-subscription.e2e.test.ts`, `session.test.ts`.
---
## Slice 5 — WorkspaceController (XL)
**Move:** all workspace handlers (incl. re-homed `handleProjectRenameRequest` and `start_workspace_script_request`) + ~25 private workspace helpers + the whole `workspaceUpdatesSubscription` state machine → `packages/server/src/server/workspace/workspace-controller.ts`.
**SessionContext surface:** `emit`, `sessionLogger`, `projectRegistry`/`workspaceRegistry`/`downloadTokenStore`/script stores/editor cache (**owned**), `workspaceGitService` + watch maps (**shared with checkout**), injected `emitCheckoutStatusUpdate`/`onBranchChanged`, `terminalManager`/`killTerminalsUnderPath`, an `agentUpdatesSubscription` write via a narrow `bufferAgentUpdate` command, `providerSnapshotManager.listRegisteredProviderIds`.
**Behavior note:** the workspaceUpdatesSubscription machine moves WHOLE. The eight already-public workspace methods stay a public surface re-exposed via the shell. Re-homes are atomic remove-from-old-dispatcher + add-to-new-owned-set. **Tests:** `session.workspaces.test.ts`, `session.workspace-git-watch.test.ts`, `session.workspace-resolution-invariants.test.ts`, `session.test.ts`.
---
## Slice 6 — Voice prerequisites (emit purity + abort ownership)
In-place, separately reviewable. Split the `audio_output` TTS-debug branch out of `emit()` (8421-8468, bypasses to `onMessage` at 8454) so `emit` is a pure trace+onMessage sink. Move `convertPCMToWavBuffer` (674-701) to `speech/audio.ts`. Decide abortController ownership.
**Behavior note:** TTS-debug split and abortController ownership are the SAME decision (`ttsDebugStreams.clear()` is tied to `createAbortController` reassignment at 8359). Keep `emit` (with the universal trace) on the shell and inject it everywhere — no trace-less emit. Do NOT inject the AbortController by value. Add: a TTS-debug persistence test (with the debug env flag) before the move, and a barge-in→cleanup regression test asserting the NEW run's signal is aborted. **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
**Behavior note:** depends on Slices 3 + 6. `cleanup()` stays the ordered orchestrator and calls `voiceController.dispose()` at the position the inlined voice teardown occupies today (8505-8525). **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-local-agent.e2e.test.ts`, `session.voice-mcp-config.test.ts`, `session.test.ts`.
---
## Slice 8a — Agent-lifecycle config setters
Parameterize the 4 setter envelopes `handleSetAgentMode/Model/Feature/Thinking` (4209-4390) into one helper; re-home `handleListCommandsRequest` (misfiled in `dispatchMiscMessage`). Add a handleMessage-driven **failure** test per setter (force the command to reject, assert both the `*_response{accepted:false}` AND the `activity_log` error frame in order) BEFORE collapsing. **Tests:** `session.test.ts`, `session.lifecycle-boundary.test.ts`.
## Slice 8b — AgentLifecycleController (XL, LAST)
**Move:** remaining lifecycle handlers + the `agentUpdatesSubscription` fan-out (`bufferOrEmitAgentUpdate`, `flushBootstrappedAgentUpdates`, `matchesAgentFilter`, `forwardAgentUpdate`) → `packages/server/src/server/agent/agent-lifecycle-controller.ts`. Collapse the three lifecycle dispatchers.
**SessionContext surface:** `emit`, `sessionLogger`, `agentManager`/`agentStorage` (**owned**), injected `forwardAgentUpdate` → `buildProjectPlacementForCwd` (backed by WorkspaceController), `agentUpdatesSubscription` accessor (owned; workspace writes via `bufferAgentUpdate`), `isProviderVisibleToClient`, `resolveCreateAgentWorkspace`, `supports`, `mcpBaseUrl`, `terminalController.killTerminalForClose`.
**Behavior note:** done LAST — the shared-projection hub. `handleCloseItemsRequest` splits its terminal-kill half from its agent-archive half. **Tests:** `session.test.ts`, `session.wait-for-finish.test.ts`, `session.create-agent-title.test.ts`, `session.lifecycle-boundary.test.ts`, `daemon-client.e2e.test.ts`.
---
## Cross-cutting invariants (every slice)
- **Always** run `npm run typecheck` and `npm run lint` after each slice; run `npm run build:server` before diagnosing cross-package type errors.
- Controller `dispatch` is **NON-async**, guarded by an owned-type `ReadonlySet` check returning `undefined` synchronously on miss. Never `async dispatch`.
- Controllers add **no** try/catch inside `dispatch` — error handling stays in `handleMessage`.
- `cleanup()` stays the single ordered teardown orchestrator on the shell.
- Move domain error emitters **verbatim**; treat any cross-domain emitter merge as a separate, test-guarded change.
- Per-slice typecheck/lint/format via `npm run` scripts; never re-run the full suite locally (run only the listed files with `--bail=1`).
+1 -1
View File
@@ -1 +1 @@
sha256-PYbY3Lk9+y2byl1mN9dVO4YGXLEZrmnuPYxDw59LE5g=
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
+629 -47
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.86",
"version": "0.1.87",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.86",
"version": "0.1.87",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -10120,6 +10120,588 @@
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/metro-babel-transformer": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz",
"integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@react-native/babel-preset": "0.85.3",
"hermes-parser": "0.33.3",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-plugin-codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz",
"integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.29.0",
"@react-native/codegen": "0.85.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-preset": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz",
"integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/plugin-proposal-export-default-from": "^7.24.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-export-default-from": "^7.24.7",
"@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
"@babel/plugin-syntax-optional-chaining": "^7.8.3",
"@babel/plugin-transform-async-generator-functions": "^7.25.4",
"@babel/plugin-transform-async-to-generator": "^7.24.7",
"@babel/plugin-transform-block-scoping": "^7.25.0",
"@babel/plugin-transform-class-properties": "^7.25.4",
"@babel/plugin-transform-classes": "^7.25.4",
"@babel/plugin-transform-destructuring": "^7.24.8",
"@babel/plugin-transform-flow-strip-types": "^7.25.2",
"@babel/plugin-transform-for-of": "^7.24.7",
"@babel/plugin-transform-modules-commonjs": "^7.24.8",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
"@babel/plugin-transform-optional-catch-binding": "^7.24.7",
"@babel/plugin-transform-optional-chaining": "^7.24.8",
"@babel/plugin-transform-private-methods": "^7.24.7",
"@babel/plugin-transform-private-property-in-object": "^7.24.7",
"@babel/plugin-transform-react-display-name": "^7.24.7",
"@babel/plugin-transform-react-jsx": "^7.25.2",
"@babel/plugin-transform-react-jsx-self": "^7.24.7",
"@babel/plugin-transform-react-jsx-source": "^7.24.7",
"@babel/plugin-transform-regenerator": "^7.24.7",
"@babel/plugin-transform-runtime": "^7.24.7",
"@babel/plugin-transform-typescript": "^7.25.2",
"@babel/plugin-transform-unicode-regex": "^7.24.7",
"@react-native/babel-plugin-codegen": "0.85.3",
"babel-plugin-syntax-hermes-parser": "0.33.3",
"babel-plugin-transform-flow-enums": "^0.0.2",
"react-refresh": "^0.14.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz",
"integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/parser": "^7.29.0",
"hermes-parser": "0.33.3",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"tinyglobby": "^0.2.15",
"yargs": "^17.6.2"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
"integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-parser": "0.33.3"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz",
"integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz",
"integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-estree": "0.33.3"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@react-native/metro-config": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz",
"integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-native/js-polyfills": "0.85.3",
"@react-native/metro-babel-transformer": "0.85.3",
"metro-config": "^0.84.3",
"metro-runtime": "^0.84.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@react-native/metro-config/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",
"peer": true,
"engines": {
"node": ">= 14"
}
},
"node_modules/@react-native/metro-config/node_modules/ci-info": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-config/node_modules/hermes-estree": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz",
"integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-config/node_modules/hermes-parser": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz",
"integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-estree": "0.35.0"
}
},
"node_modules/@react-native/metro-config/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",
"peer": true,
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@react-native/metro-config/node_modules/metro": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz",
"integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"accepts": "^2.0.0",
"ci-info": "^2.0.0",
"connect": "^3.6.5",
"debug": "^4.4.0",
"error-stack-parser": "^2.0.6",
"flow-enums-runtime": "^0.0.6",
"graceful-fs": "^4.2.4",
"hermes-parser": "0.35.0",
"image-size": "^1.0.2",
"invariant": "^2.2.4",
"jest-worker": "^29.7.0",
"jsc-safe-url": "^0.2.2",
"lodash.throttle": "^4.1.1",
"metro-babel-transformer": "0.84.4",
"metro-cache": "0.84.4",
"metro-cache-key": "0.84.4",
"metro-config": "0.84.4",
"metro-core": "0.84.4",
"metro-file-map": "0.84.4",
"metro-resolver": "0.84.4",
"metro-runtime": "0.84.4",
"metro-source-map": "0.84.4",
"metro-symbolicate": "0.84.4",
"metro-transform-plugins": "0.84.4",
"metro-transform-worker": "0.84.4",
"mime-types": "^3.0.1",
"nullthrows": "^1.1.1",
"serialize-error": "^2.1.0",
"source-map": "^0.5.6",
"throat": "^5.0.0",
"ws": "^7.5.10",
"yargs": "^17.6.2"
},
"bin": {
"metro": "src/cli.js"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-babel-transformer": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz",
"integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"flow-enums-runtime": "^0.0.6",
"hermes-parser": "0.35.0",
"metro-cache-key": "0.84.4",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-cache": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz",
"integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==",
"license": "MIT",
"peer": true,
"dependencies": {
"exponential-backoff": "^3.1.1",
"flow-enums-runtime": "^0.0.6",
"https-proxy-agent": "^7.0.5",
"metro-core": "0.84.4"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-cache-key": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz",
"integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-config": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz",
"integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"connect": "^3.6.5",
"flow-enums-runtime": "^0.0.6",
"jest-validate": "^29.7.0",
"metro": "0.84.4",
"metro-cache": "0.84.4",
"metro-core": "0.84.4",
"metro-runtime": "0.84.4",
"yaml": "^2.6.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-core": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz",
"integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"lodash.throttle": "^4.1.1",
"metro-resolver": "0.84.4"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-file-map": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz",
"integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"fb-watchman": "^2.0.0",
"flow-enums-runtime": "^0.0.6",
"graceful-fs": "^4.2.4",
"invariant": "^2.2.4",
"jest-worker": "^29.7.0",
"micromatch": "^4.0.4",
"nullthrows": "^1.1.1",
"walker": "^1.0.7"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-minify-terser": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz",
"integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"terser": "^5.15.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-resolver": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz",
"integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-runtime": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz",
"integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.25.0",
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-source-map": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz",
"integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"invariant": "^2.2.4",
"metro-symbolicate": "0.84.4",
"nullthrows": "^1.1.1",
"ob1": "0.84.4",
"source-map": "^0.5.6",
"vlq": "^1.0.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-symbolicate": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz",
"integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"invariant": "^2.2.4",
"metro-source-map": "0.84.4",
"nullthrows": "^1.1.1",
"source-map": "^0.5.6",
"vlq": "^1.0.0"
},
"bin": {
"metro-symbolicate": "src/index.js"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-transform-plugins": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz",
"integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-transform-worker": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz",
"integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"metro": "0.84.4",
"metro-babel-transformer": "0.84.4",
"metro-cache": "0.84.4",
"metro-cache-key": "0.84.4",
"metro-minify-terser": "0.84.4",
"metro-source-map": "0.84.4",
"metro-transform-plugins": "0.84.4",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@react-native/metro-config/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@react-native/metro-config/node_modules/ob1": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz",
"integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@react-native/metro-config/node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@react-native/normalize-colors": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.6.tgz",
@@ -30808,19 +31390,18 @@
}
},
"node_modules/react-native-reanimated": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
"integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
"integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1",
"semver": "7.7.2"
"react-native-is-edge-to-edge": "^1.3.1",
"semver": "^7.7.3"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0",
"react": "*",
"react-native": "*",
"react-native-worklets": ">=0.5.0"
"react-native": "0.81 - 0.85",
"react-native-worklets": "0.8.x"
}
},
"node_modules/react-native-reanimated/node_modules/semver": {
@@ -30962,27 +31543,28 @@
}
},
"node_modules/react-native-worklets": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
"integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0",
"@babel/plugin-transform-classes": "^7.0.0-0",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0",
"@babel/plugin-transform-optional-chaining": "^7.0.0-0",
"@babel/plugin-transform-shorthand-properties": "^7.0.0-0",
"@babel/plugin-transform-template-literals": "^7.0.0-0",
"@babel/plugin-transform-unicode-regex": "^7.0.0-0",
"@babel/preset-typescript": "^7.16.7",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-classes": "^7.28.4",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
"@babel/plugin-transform-optional-chaining": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"convert-source-map": "^2.0.0",
"semver": "7.7.2"
"semver": "^7.7.3"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0",
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "*"
"react-native": "0.81 - 0.85"
}
},
"node_modules/react-native-worklets/node_modules/semver": {
@@ -36303,7 +36885,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36360,14 +36942,14 @@
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
@@ -36375,7 +36957,7 @@
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
@@ -36528,12 +37110,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.86",
"@getpaseo/protocol": "0.1.86",
"@getpaseo/server": "0.1.86",
"@getpaseo/client": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/server": "0.1.87",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36779,10 +37361,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@getpaseo/protocol": "0.1.86",
"@getpaseo/relay": "0.1.86",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -36802,7 +37384,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.86",
"version": "0.1.87",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37054,7 +37636,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.86",
"version": "0.1.87",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37090,7 +37672,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37319,7 +37901,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37340,7 +37922,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37558,14 +38140,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.86",
"@getpaseo/highlight": "0.1.86",
"@getpaseo/protocol": "0.1.86",
"@getpaseo/relay": "0.1.86",
"@getpaseo/client": "0.1.87",
"@getpaseo/highlight": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38337,7 +38919,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -121,6 +121,8 @@
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-native-reanimated": "4.3.1",
"react-native-worklets": "0.8.3"
}
}
@@ -0,0 +1,126 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgentAtMobileBreakpoint(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "bottom-sheet-reopen-",
title: "Bottom sheet reopen e2e",
initialPrompt: "Prepare a bottom sheet reopen test agent.",
});
await openAgentRoute(page, session);
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
timeout: 30_000,
});
return session;
}
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await run();
} finally {
await session.cleanup();
}
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
function bottomSheetHandle(page: Page) {
return page.getByRole("slider", { name: "Bottom sheet handle" }).first();
}
async function expectBottomSheetOpen(page: Page) {
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
}
async function closeBottomSheetWithBackdrop(page: Page) {
const backdrop = bottomSheetBackdrop(page);
const handle = bottomSheetHandle(page);
// Tapping the backdrop is the close path under test, but on a loaded CI runner
// the model-selector sheet re-renders as its model list settles and Gorhom
// drops backdrop presses during that churn — so a tap (even retried) can fail
// to dismiss. Tap the backdrop first; if it survives, drag the handle down,
// which drives Gorhom's pan-to-close directly and is unaffected by the churn.
// The post-close guard below still protects the regression this test exists
// for: a sheet that dismisses, then re-presents.
await expect(async () => {
if (!(await backdrop.isVisible())) {
return;
}
const box = await backdrop.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + 24);
}
await page.waitForTimeout(150);
if (await backdrop.isVisible()) {
const handleBox = await handle.boundingBox();
if (handleBox) {
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 400, { steps: 8 });
await page.mouse.up();
}
}
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
// Guard against the regression where the sheet starts dismissing, then re-presents.
await page.waitForTimeout(500);
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}
async function openTabSwitcher(page: Page) {
const trigger = page.getByRole("button", { name: /Switch tabs/ });
await trigger.click();
await expectBottomSheetOpen(page);
}
async function openModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expectBottomSheetOpen(page);
await expect(
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
}
async function openAndCloseTabSwitcherTwice(page: Page) {
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
}
async function openAndCloseModelSelectorTwice(page: Page) {
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
}
test.describe("mobile bottom sheet reopen", () => {
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseTabSwitcherTwice(page);
});
});
test("model selector can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseModelSelectorTwice(page);
});
});
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"main": "index.ts",
"scripts": {
@@ -85,14 +85,14 @@
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
@@ -100,7 +100,7 @@
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
+1
View File
@@ -423,6 +423,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
deliveryHint={item.deliveryHint}
/>
);
},
+21 -1
View File
@@ -63,7 +63,11 @@ import Animated, {
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Fonts } from "@/constants/theme";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type {
TodoEntry,
UserMessageDeliveryHint,
UserMessageImageAttachment,
} from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
@@ -124,6 +128,7 @@ interface UserMessageProps {
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
deliveryHint?: UserMessageDeliveryHint;
}
const MessageOuterSpacingContext = createContext(false);
@@ -434,6 +439,12 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
deliveryHintLabel: {
alignSelf: "flex-end",
marginTop: theme.spacing[1],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
@@ -475,6 +486,7 @@ export const UserMessage = memo(function UserMessage({
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
deliveryHint,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
@@ -589,6 +601,14 @@ export const UserMessage = memo(function UserMessage({
/>
</View>
) : null}
{deliveryHint === "steering" ? (
<Text
accessibilityLabel="Steering conversation"
style={userMessageStylesheet.deliveryHintLabel}
>
Steering conversation
</Text>
) : null}
</View>
</View>
);
@@ -118,4 +118,44 @@ describe("bottom sheet visibility tracker", () => {
tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(2);
});
it("does not re-present when the controller reattaches before parent state acknowledges a user dismiss", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.attachController(null);
tracker.attachController(sheet);
expect(closeCount()).toBe(1);
expect(sheet.events).toEqual([{ type: "present" }]);
});
it("does not re-present when dismiss fires before parent state acknowledges a user dismiss", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetDismiss();
tracker.attachController(null);
tracker.attachController(sheet);
expect(closeCount()).toBe(1);
expect(sheet.events).toEqual([{ type: "present" }]);
});
it("allows a fresh open after parent state acknowledges a dismissed sheet", () => {
const { sheet, tracker } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.attachController(null);
tracker.attachController(sheet);
tracker.syncDesired({ visible: false });
tracker.syncDesired({ visible: true });
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
});
});
@@ -15,25 +15,27 @@ export interface BottomSheetVisibilityTracker {
handleSheetDismiss(): void;
}
type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
export function createBottomSheetVisibilityTracker(opts: {
onClose: () => void;
}): BottomSheetVisibilityTracker {
let controller: BottomSheetController | null = null;
let visible = false;
let isEnabled: boolean | undefined;
let isPresented = false;
let phase: BottomSheetPhase = "closed";
let hasNotifiedClose = false;
function present(): void {
if (!controller || isPresented) return;
isPresented = true;
if (!controller || phase !== "closed") return;
phase = "presenting";
hasNotifiedClose = false;
controller.present();
}
function dismiss(): void {
if (!controller || !isPresented) return;
isPresented = false;
if (!controller || phase === "closed" || phase === "dismissing") return;
phase = "dismissing";
controller.dismiss();
}
@@ -58,19 +60,34 @@ export function createBottomSheetVisibilityTracker(opts: {
present();
return;
}
if (phase === "dismissing") {
phase = "closed";
hasNotifiedClose = false;
return;
}
dismiss();
},
handleSheetIndexChange(index) {
if (index === -1 && visible) {
if (index !== -1) {
if (phase === "presenting") {
phase = "presented";
}
return;
}
if (phase === "presenting" || phase === "presented") {
phase = "dismissing";
}
if (visible) {
notifyClose();
}
},
handleSheetDismiss() {
isPresented = false;
if (visible) {
phase = "dismissing";
notifyClose();
return;
}
phase = "closed";
hasNotifiedClose = false;
},
};
+38
View File
@@ -417,6 +417,44 @@ describe("dispatchComposerAgentMessage", () => {
expect(client.calls[0]?.options.images).toEqual([]);
});
it("attaches a steering deliveryHint to the optimistic user_message when provided", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "steer this",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
deliveryHint: "steering",
});
const tail = stream.tail.get("agent");
expect(tail).toHaveLength(1);
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
expect(userMessage.deliveryHint).toBe("steering");
});
it("omits deliveryHint when none is supplied (no marker for non-steering sends)", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "no steering",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
});
const tail = stream.tail.get("agent");
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
expect(userMessage.deliveryHint).toBeUndefined();
});
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
+3
View File
@@ -14,6 +14,7 @@ import {
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageDeliveryHint,
type UserMessageItem,
} from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
@@ -130,6 +131,7 @@ export interface DispatchComposerAgentMessageInput {
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
stream: AgentStreamWriter;
deliveryHint?: UserMessageDeliveryHint;
}
export async function dispatchComposerAgentMessage(
@@ -143,6 +145,7 @@ export async function dispatchComposerAgentMessage(
timestamp: new Date(),
images: wirePayload.images,
attachments: wirePayload.attachments,
deliveryHint: input.deliveryHint,
});
appendUserMessageToStream(input.agentId, userMessage, input.stream);
const imagesData = await input.encodeImages(wirePayload.images);
+4
View File
@@ -1070,6 +1070,9 @@ export function Composer({
setHead: (updater) => setAgentStreamHead(serverId, updater),
setTail: (updater) => setAgentStreamTail(serverId, updater),
};
const targetAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(targetAgentId);
const isSteering =
targetAgent?.status === "running" && targetAgent.capabilities.supportsSteering === true;
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
@@ -1077,6 +1080,7 @@ export function Composer({
attachments: sendAttachments,
encodeImages,
stream,
...(isSteering ? { deliveryHint: "steering" as const } : {}),
});
onAttentionPromptSend?.();
};
@@ -1700,6 +1700,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
attachments?: AgentAttachment[],
) => {
const messageId = generateMessageId();
const targetAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const isSteering =
targetAgent?.status === "running" && targetAgent.capabilities.supportsSteering === true;
const userMessage: StreamItem = {
kind: "user_message",
id: messageId,
@@ -1708,6 +1711,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
optimistic: true,
...(images && images.length > 0 ? { images } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
...(isSteering ? { deliveryHint: "steering" as const } : {}),
};
// Append to head if streaming (keeps the user message with the current
+46
View File
@@ -765,6 +765,52 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime());
});
it("preserves optimistic steering deliveryHint when authoritative user message arrives", () => {
const messageId = "msg-user-steering";
const initialState: StreamItem[] = [
{
kind: "user_message",
id: messageId,
text: "Steer to a different approach",
timestamp: new Date("2025-01-01T11:10:00Z"),
deliveryHint: "steering",
},
];
const event: AgentStreamEventPayload = {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text: "Steer to a different approach",
messageId,
},
};
const state = reduceStreamUpdate(initialState, event, new Date("2025-01-01T11:10:01Z"));
const message = state.find((item) => item.kind === "user_message");
assert.ok(message);
assert.strictEqual(message.deliveryHint, "steering");
});
it("does not invent a deliveryHint for canonical user messages without prior optimistic state", () => {
const event: AgentStreamEventPayload = {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text: "Hello there",
messageId: "canonical-only",
},
};
const state = reduceStreamUpdate([], event, new Date("2025-01-01T11:11:00Z"));
const message = state.find((item) => item.kind === "user_message");
assert.ok(message);
assert.strictEqual(message.deliveryHint, undefined);
});
it("keeps canonical assistant/user/assistant order during replay", () => {
const state: StreamItem[] = [
{
+12
View File
@@ -54,6 +54,8 @@ export type StreamItem =
export type UserMessageImageAttachment = AttachmentMetadata;
export type UserMessageDeliveryHint = "steering";
export interface UserMessageItem {
kind: "user_message";
id: string;
@@ -62,6 +64,13 @@ export interface UserMessageItem {
optimistic?: true;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
/**
* Local-only marker set when this user message was sent optimistically
* while the agent was running and the provider supports steering. Never
* sent or received over the wire — preserved across canonical replays so
* the UI can keep showing the marker.
*/
deliveryHint?: UserMessageDeliveryHint;
}
export interface OptimisticUserMessageInput {
@@ -70,6 +79,7 @@ export interface OptimisticUserMessageInput {
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
deliveryHint?: UserMessageDeliveryHint;
}
export type OptimisticUserMessagePlacement = "tail" | "active-head";
@@ -213,6 +223,7 @@ function buildUserMessageItem(input: {
...(input.optimistic.attachments && input.optimistic.attachments.length > 0
? { attachments: input.optimistic.attachments }
: {}),
...(input.optimistic.deliveryHint ? { deliveryHint: input.optimistic.deliveryHint } : {}),
};
}
@@ -235,6 +246,7 @@ export function buildOptimisticUserMessage(input: OptimisticUserMessageInput): U
...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments }
: {}),
...(input.deliveryHint ? { deliveryHint: input.deliveryHint } : {}),
};
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -25,9 +25,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.86",
"@getpaseo/protocol": "0.1.86",
"@getpaseo/server": "0.1.86",
"@getpaseo/client": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/server": "0.1.87",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -33,8 +33,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.86",
"@getpaseo/relay": "0.1.86",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"zod": "^3.23.8"
},
"devDependencies": {
+1
View File
@@ -152,6 +152,7 @@ function createAgent(input: Partial<PaseoAgent> = {}): PaseoAgent {
supportsRewindBoth: false,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsSteering: false,
supportsToolInvocations: true,
},
currentModeId: null,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.86",
"version": "0.1.87",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.86",
"version": "0.1.87",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1
View File
@@ -125,6 +125,7 @@ export interface AgentCapabilityFlags {
supportsRewindConversation?: boolean;
supportsRewindFiles?: boolean;
supportsRewindBoth?: boolean;
supportsSteering?: boolean;
}
export interface AgentPersistenceHandle {
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
AgentSnapshotPayloadSchema,
GetProvidersSnapshotResponseMessageSchema,
ProviderSnapshotEntrySchema,
ProvidersSnapshotUpdateMessageSchema,
@@ -16,6 +17,34 @@ describe("provider snapshot message schemas", () => {
expect(parsed.enabled).toBe(true);
});
test("defaults missing agent capability steering support to false", () => {
const parsed = AgentSnapshotPayloadSchema.parse({
id: "agent-1",
provider: "codex",
cwd: "/tmp/repo",
model: null,
createdAt: "2026-04-24T00:00:00.000Z",
updatedAt: "2026-04-24T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
});
expect(parsed.capabilities.supportsSteering).toBe(false);
});
test("preserves disabled provider snapshot entries", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "claude",
+2
View File
@@ -251,6 +251,8 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
supportsRewindFiles: z.boolean().optional().default(false),
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
supportsRewindBoth: z.boolean().optional().default(false),
// COMPAT(steering): added in v0.1.X, drop when floor >= v0.1.X.
supportsSteering: z.boolean().optional().default(false),
});
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -57,10 +57,10 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.86",
"@getpaseo/highlight": "0.1.86",
"@getpaseo/protocol": "0.1.86",
"@getpaseo/relay": "0.1.86",
"@getpaseo/client": "0.1.87",
"@getpaseo/highlight": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -18,6 +18,7 @@ import type {
AgentLaunchContext,
AgentProvider,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunResult,
AgentSession,
AgentSessionConfig,
@@ -3010,6 +3011,314 @@ test("replaceAgentRun does not emit idle or resolve waiters between interrupted
unsubscribe();
});
test("startAgentRun applies the replace-vs-stream policy in AgentManager", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-start-policy-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000126",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const replacementEvents = (async function* replacementEvents() {})();
const streamEvents = (async function* streamEvents() {})();
const hasInFlightRunSpy = vi.spyOn(manager, "hasInFlightRun").mockReturnValue(true);
const replaceAgentRunSpy = vi
.spyOn(manager, "replaceAgentRun")
.mockReturnValue(replacementEvents);
const streamAgentSpy = vi.spyOn(manager, "streamAgent").mockReturnValue(streamEvents);
const replacement = manager.startAgentRun(snapshot.id, "replace me", {
replaceRunning: true,
});
expect(replacement).toEqual({ outOfBand: false, events: replacementEvents });
expect(hasInFlightRunSpy).toHaveBeenCalledWith(snapshot.id);
expect(replaceAgentRunSpy).toHaveBeenCalledWith(snapshot.id, "replace me", undefined);
expect(streamAgentSpy).not.toHaveBeenCalled();
replaceAgentRunSpy.mockClear();
streamAgentSpy.mockClear();
const streamed = manager.startAgentRun(snapshot.id, "stream me", {
replaceRunning: false,
});
expect(streamed).toEqual({ outOfBand: false, events: streamEvents });
expect(streamAgentSpy).toHaveBeenCalledWith(snapshot.id, "stream me", undefined);
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
});
test("startAgentRun steers a running foreground turn when the provider supports steering", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-run-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const allowFirstRunToEnd = deferred<void>();
const steeredPrompts: AgentPromptInput[] = [];
let capturedSession: SteerableSession | null = null;
const steeringCapabilities = {
...TEST_CAPABILITIES,
supportsSteering: true,
} as const;
class SteerableSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = steeringCapabilities;
readonly id = randomUUID();
readonly interrupt = vi.fn(async () => undefined);
private subscribers = new Set<(event: AgentStreamEvent) => void>();
private turnIdCounter = 0;
async run(): Promise<AgentRunResult> {
return {
sessionId: this.id,
finalText: "",
timeline: [],
};
}
async startTurn(): Promise<{ turnId: string }> {
const turnId = `turn-${++this.turnIdCounter}`;
void (async () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
await allowFirstRunToEnd.promise;
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
})();
return { turnId };
}
async steerTurn(prompt: AgentPromptInput): Promise<void> {
steeredPrompts.push(prompt);
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
private pushEvent(event: AgentStreamEvent): void {
for (const callback of this.subscribers) {
callback(event);
}
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return { provider: this.provider, sessionId: this.id };
}
async close(): Promise<void> {}
}
class SteerableClient extends TestAgentClient {
readonly capabilities = steeringCapabilities;
override async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
capturedSession = new SteerableSession();
return capturedSession;
}
}
const manager = new AgentManager({
clients: {
codex: new SteerableClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000127",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const firstRun = manager.streamAgent(snapshot.id, "first run");
const firstRunDrain = (async () => {
for await (const _event of firstRun) {
// Drain the original foreground run.
}
})();
await manager.waitForAgentRunStart(snapshot.id);
const steered = manager.startAgentRun(snapshot.id, "steer this", {
replaceRunning: true,
});
expect(steered.outOfBand).toBe(false);
if (!steered.outOfBand) {
for await (const _event of steered.events) {
// Steering produces no foreground stream events.
}
}
expect(steeredPrompts).toEqual(["steer this"]);
expect(capturedSession?.interrupt).not.toHaveBeenCalled();
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("running");
allowFirstRunToEnd.resolve();
await firstRunDrain;
});
test("startAgentRun replaces a running foreground turn when steering is unsupported", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unsupported-steer-run-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const allowFirstRunToCancel = deferred<void>();
const allowSecondRunToEnd = deferred<void>();
let startTurnCount = 0;
let interruptCount = 0;
class UnsupportedSteeringSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private subscribers = new Set<(event: AgentStreamEvent) => void>();
async run(): Promise<AgentRunResult> {
return {
sessionId: this.id,
finalText: "",
timeline: [],
};
}
async startTurn(): Promise<{ turnId: string }> {
startTurnCount += 1;
const turnId = `turn-${startTurnCount}`;
const turnNumber = startTurnCount;
void (async () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
if (turnNumber === 1) {
await allowFirstRunToCancel.promise;
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
reason: "interrupted",
turnId,
});
return;
}
await allowSecondRunToEnd.promise;
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
})();
return { turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
private pushEvent(event: AgentStreamEvent): void {
for (const callback of this.subscribers) {
callback(event);
}
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {
interruptCount += 1;
allowFirstRunToCancel.resolve();
}
async close(): Promise<void> {}
}
class UnsupportedSteeringClient extends TestAgentClient {
override async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
return new UnsupportedSteeringSession();
}
}
const manager = new AgentManager({
clients: {
codex: new UnsupportedSteeringClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000128",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const firstRun = manager.streamAgent(snapshot.id, "first run");
const firstRunDrain = (async () => {
for await (const _event of firstRun) {
// Drain the original foreground run.
}
})();
await manager.waitForAgentRunStart(snapshot.id);
const replacement = manager.startAgentRun(snapshot.id, "replace this", {
replaceRunning: true,
});
expect(replacement.outOfBand).toBe(false);
const replacementDrain =
replacement.outOfBand === false
? (async () => {
for await (const _event of replacement.events) {
// Drain the replacement foreground run.
}
})()
: Promise.resolve();
await manager.waitForAgentRunStart(snapshot.id);
expect(interruptCount).toBe(1);
expect(startTurnCount).toBe(2);
allowSecondRunToEnd.resolve();
await firstRunDrain;
await replacementDrain;
});
test("replaceAgentRun stays running when a stale old terminal arrives before the replacement turn is current", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-stale-terminal-"));
const storagePath = join(workdir, "agents");
@@ -205,6 +205,15 @@ export interface WaitForAgentStartOptions {
signal?: AbortSignal;
}
export interface StartAgentRunOptions {
replaceRunning?: boolean;
runOptions?: AgentRunOptions;
}
export type StartAgentRunResult =
| { outOfBand: true }
| { outOfBand: false; events: AsyncGenerator<AgentStreamEvent> };
type AttentionState =
| { requiresAttention: false }
| {
@@ -1482,6 +1491,55 @@ export class AgentManager {
return true;
}
startAgentRun(
agentId: string,
prompt: AgentPromptInput,
options?: StartAgentRunOptions,
): StartAgentRunResult {
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
// in-flight turn. Keeping this policy here makes every send surface share
// the same replace-vs-stream decision.
if (this.tryRunOutOfBand(agentId, prompt)) {
return { outOfBand: true };
}
const shouldReplace = Boolean(options?.replaceRunning && this.hasInFlightRun(agentId));
const runOptions = options?.runOptions;
if (shouldReplace && this.canSteerActiveForegroundRun(agentId)) {
const events = this.steerAgentRun(agentId, prompt, runOptions);
return { outOfBand: false, events };
}
const events = shouldReplace
? this.replaceAgentRun(agentId, prompt, runOptions)
: this.streamAgent(agentId, prompt, runOptions);
return { outOfBand: false, events };
}
private canSteerActiveForegroundRun(agentId: string): boolean {
const agent = this.agents.get(agentId);
return Boolean(
agent?.activeForegroundTurnId &&
agent.capabilities.supportsSteering === true &&
agent.session.steerTurn,
);
}
private async *steerAgentRun(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
const agent = this.requireSessionAgent(agentId);
if (!agent.session.steerTurn) {
throw new Error(`Agent ${agentId} does not support steering`);
}
await agent.session.steerTurn(prompt, options);
this.touchUpdatedAt(agent);
this.emitState(agent);
yield* [];
}
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
const agent = this.requireAgent(agentId);
this.touchUpdatedAt(agent);
@@ -10,6 +10,9 @@ import {
} from "./agent-prompt.js";
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
const CHILD_AGENT_ID = "11111111-1111-4111-8111-111111111111";
const CALLER_AGENT_ID = "22222222-2222-4222-8222-222222222222";
test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationPrompt produces", () => {
expect(isSystemInjectedEnvelope(formatSystemNotificationPrompt("child finished"))).toBe(true);
expect(isSystemInjectedEnvelope("hello world")).toBe(false);
@@ -19,27 +22,24 @@ it("does not notify archived callers", async () => {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent");
Reflect.set(childAgent, "id", CHILD_AGENT_ID);
Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" });
const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", "caller-agent");
Reflect.set(callerAgent, "id", CALLER_AGENT_ID);
Reflect.set(callerAgent, "lifecycle", "idle");
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
const streamAgentSpy = vi.fn(() => (async function* noop() {})());
const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})());
const agentManager: AgentManager = Object.create(AgentManager.prototype);
Reflect.set(
agentManager,
"getAgent",
vi.fn((agentId: string) => {
if (agentId === "child-agent") {
if (agentId === CHILD_AGENT_ID) {
return childAgent;
}
if (agentId === "caller-agent") {
if (agentId === CALLER_AGENT_ID) {
return callerAgent;
}
return null;
@@ -55,12 +55,14 @@ it("does not notify archived callers", async () => {
};
}),
);
Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false));
Reflect.set(agentManager, "streamAgent", streamAgentSpy);
Reflect.set(agentManager, "replaceAgentRun", replaceAgentRunSpy);
const startAgentRunSpy = vi.fn(() => ({
outOfBand: false,
events: (async function* noop() {})(),
}));
Reflect.set(agentManager, "startAgentRun", startAgentRunSpy);
const agentStorageGetSpy = vi.fn(async (agentId: string) =>
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
agentId === CALLER_AGENT_ID ? { archivedAt: "2024-01-01" } : null,
);
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
Reflect.set(agentStorage, "get", agentStorageGetSpy);
@@ -68,8 +70,8 @@ it("does not notify archived callers", async () => {
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
childAgentId: CHILD_AGENT_ID,
callerAgentId: CALLER_AGENT_ID,
logger: createTestLogger(),
});
@@ -88,9 +90,92 @@ it("does not notify archived callers", async () => {
});
await vi.waitFor(() => {
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
expect(agentStorageGetSpy).toHaveBeenCalledWith(CALLER_AGENT_ID);
});
expect(streamAgentSpy).not.toHaveBeenCalled();
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
expect(startAgentRunSpy).not.toHaveBeenCalled();
});
it("uses AgentManager startAgentRun for finish notifications", async () => {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", CHILD_AGENT_ID);
Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" });
const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", CALLER_AGENT_ID);
Reflect.set(callerAgent, "lifecycle", "idle");
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
const startAgentRunSpy = vi.fn(() => ({
outOfBand: false,
events: (async function* noop() {})(),
}));
const agentManager: AgentManager = Object.create(AgentManager.prototype);
Reflect.set(
agentManager,
"getAgent",
vi.fn((agentId: string) => {
if (agentId === CHILD_AGENT_ID) {
return childAgent;
}
if (agentId === CALLER_AGENT_ID) {
return callerAgent;
}
return null;
}),
);
Reflect.set(
agentManager,
"subscribe",
vi.fn((callback: (event: AgentManagerEvent) => void) => {
subscriber = callback;
return () => {
subscriber = null;
};
}),
);
Reflect.set(agentManager, "startAgentRun", startAgentRunSpy);
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
Reflect.set(
agentStorage,
"get",
vi.fn(async (agentId: string) =>
agentId === CHILD_AGENT_ID ? { title: "Child Agent" } : null,
),
);
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: CHILD_AGENT_ID,
callerAgentId: CALLER_AGENT_ID,
logger: createTestLogger(),
});
expect(subscriber).not.toBeNull();
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "idle";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
await vi.waitFor(() => {
expect(startAgentRunSpy).toHaveBeenCalledWith(
CALLER_AGENT_ID,
`<paseo-system>\nAgent ${CHILD_AGENT_ID} (Child Agent) finished.\n</paseo-system>`,
{ replaceRunning: true },
);
});
});
@@ -1,19 +1,11 @@
import type { Logger } from "pino";
import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentManager, ManagedAgent, StartAgentRunOptions } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
export type AgentRunController = Pick<
AgentManager,
"getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent"
>;
export interface StartAgentRunOptions {
replaceRunning?: boolean;
runOptions?: AgentRunOptions;
}
export type AgentRunController = Pick<AgentManager, "getAgent" | "startAgentRun">;
export function startAgentRun(
agentManager: AgentRunController,
@@ -35,23 +27,17 @@ export function startAgentRun(
},
"agent.session.start_stream.request",
);
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
// in-flight turn — replaceAgentRun would interrupt the running turn. The
// intercept lives at this layer so it covers every prompt entrypoint.
if (agentManager.tryRunOutOfBand(agentId, prompt)) {
const result = agentManager.startAgentRun(agentId, prompt, options);
if (result.outOfBand) {
return { outOfBand: true };
}
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
const runOptions = options?.runOptions;
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
: agentManager.streamAgent(agentId, prompt, runOptions);
const iterator = result.events;
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
shouldReplace,
},
"agent.session.start_stream.iterator_returned",
);
@@ -153,6 +153,7 @@ export interface AgentCapabilityFlags {
supportsRewindConversation?: boolean;
supportsRewindFiles?: boolean;
supportsRewindBoth?: boolean;
supportsSteering?: boolean;
}
export interface AgentPersistenceHandle {
@@ -558,6 +559,7 @@ export interface AgentSession {
readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerTurn?(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<void>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
@@ -8,6 +8,7 @@ import type {
AgentPermissionResponse,
} from "./agent-sdk-types.js";
import type { AgentStreamEvent } from "../messages.js";
import type { StartAgentRunOptions, StartAgentRunResult } from "./agent-manager.js";
import { respondToAgentPermission } from "./permission-response.js";
class FakePermissionAgentManager {
@@ -61,6 +62,21 @@ class FakePermissionAgentManager {
this.replacementRuns.push({ agentId, prompt, options });
return emptyAgentStream();
}
startAgentRun(
agentId: string,
prompt: AgentPromptInput,
options?: StartAgentRunOptions,
): StartAgentRunResult {
if (this.tryRunOutOfBand()) {
return { outOfBand: true };
}
const shouldReplace = Boolean(options?.replaceRunning && this.hasInFlightRun());
const events = shouldReplace
? this.replaceAgentRun(agentId, prompt, options?.runOptions)
: this.streamAgent(agentId, prompt, options?.runOptions);
return { outOfBand: false, events };
}
}
async function* emptyAgentStream(): AsyncGenerator<AgentStreamEvent> {}
@@ -351,6 +351,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
},
run: (prompt, options) => inner.run(prompt, options),
startTurn: (prompt, options) => inner.startTurn(prompt, options),
steerTurn: inner.steerTurn?.bind(inner),
subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))),
async *streamHistory() {
for await (const event of inner.streamHistory()) {
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import * as executableUtils from "../../../../utils/executable.js";
@@ -749,7 +749,10 @@ describe("ClaudeAgentSession context window usage", () => {
return session as unknown as TestClaudeSession;
}
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
function createQueryFactoryForTurns(
turns: Array<Array<Record<string, unknown>>>,
onPrompt?: (prompt: unknown) => void,
) {
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const queuedMessages: Array<Record<string, unknown>> = [];
const waiters: Array<() => void> = [];
@@ -768,6 +771,7 @@ describe("ClaudeAgentSession context window usage", () => {
void (async () => {
for await (const _prompt of prompt) {
onPrompt?.(_prompt);
const turnMessages = turns[turnIndex] ?? [];
turnIndex += 1;
for (const message of turnMessages) {
@@ -1003,6 +1007,42 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("steerTurn pushes a priority next user message into the existing input stream", async () => {
const capturedPrompts: SDKUserMessage[] = [];
const queryFactory = createQueryFactoryForTurns([], (prompt) => {
capturedPrompts.push(prompt as SDKUserMessage);
});
const client = new ClaudeAgentClient({ logger, queryFactory });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
await session.startTurn("first prompt");
await vi.waitFor(() => {
expect(capturedPrompts).toHaveLength(1);
});
await expect(session.steerTurn?.("steered prompt")).resolves.toBeUndefined();
await vi.waitFor(() => {
expect(capturedPrompts).toHaveLength(2);
});
expect(capturedPrompts[1]).toMatchObject({
type: "user",
priority: "next",
message: {
role: "user",
content: [{ type: "text", text: "steered prompt" }],
},
});
expect(queryFactory).toHaveBeenCalledTimes(1);
} finally {
await session.close();
}
});
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
const session = await createSessionForTest();
@@ -216,6 +216,7 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: true,
supportsSteering: true,
};
const DEFAULT_MODES: AgentMode[] = [
@@ -1789,6 +1790,20 @@ class ClaudeAgentSession implements AgentSession {
return { turnId };
}
async steerTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<void> {
if (this.closed) {
throw new Error("Claude session is closed");
}
const sdkMessage = this.toSdkUserMessage(prompt);
sdkMessage.priority = "next";
await this.ensureQuery();
if (!this.input) {
throw new Error("Claude session input stream not initialized");
}
this.input.push(sdkMessage);
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
@@ -63,6 +63,7 @@ type CodexTestSession = AgentSession & {
connected: boolean;
currentThreadId: string | null;
activeForegroundTurnId: string | null;
activeAppServerTurnId: string | null;
client: CodexClientLike | null;
};
@@ -99,6 +100,7 @@ function createSession(
session.connected = true;
session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn";
session.activeAppServerTurnId = "test-turn";
return session;
}
@@ -395,6 +397,12 @@ describe("Codex app-server provider", () => {
);
});
test("advertises steering support for app-server sessions", () => {
const session = createSession();
expect(session.capabilities.supportsSteering).toBe(true);
});
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const fakeClient: CodexClientLike = {
@@ -880,6 +888,161 @@ describe("Codex app-server provider", () => {
);
});
test("steers against the app-server turn id returned by turn/start", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return { turn: { id: "app-server-turn-from-start" } };
}
if (method === "turn/steer") {
return { turnId: "app-server-turn-from-start" };
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
const started = await session.startTurn("Start the turn");
expect(started.turnId).not.toBe("app-server-turn-from-start");
await session.steerTurn!("Steer the active turn");
expect(request).toHaveBeenCalledWith(
"turn/steer",
expect.objectContaining({
expectedTurnId: "app-server-turn-from-start",
}),
);
});
test("waits for the app-server turn id before steering Codex", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
if (method === "turn/steer") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("Start the turn");
await expect(session.steerTurn!("Too early")).rejects.toThrow(
"Cannot steer Codex turn without an active app-server turn",
);
asInternals(session).handleNotification("turn/started", {
turn: { id: "app-server-turn-from-notification" },
});
await session.steerTurn!("Steer after notification");
expect(request).toHaveBeenCalledWith(
"turn/steer",
expect.objectContaining({
expectedTurnId: "app-server-turn-from-notification",
}),
);
});
test("sends turn/steer with thread id, built input, and active foreground turn id", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "turn/steer") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.client = createStub<CodexClientLike>({ request });
session.currentThreadId = "thread-for-steer";
session.activeForegroundTurnId = "paseo-foreground-turn-for-steer";
session.activeAppServerTurnId = "app-server-turn-for-steer";
await session.steerTurn!("Use this guidance next.");
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("turn/steer", {
threadId: "thread-for-steer",
expectedTurnId: "app-server-turn-for-steer",
input: [
{
type: "text",
text: "Use this guidance next.",
text_elements: [],
},
],
});
});
test("does not start a new turn when steering Codex", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "turn/steer") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.client = createStub<CodexClientLike>({ request });
await session.steerTurn!("Keep going with this constraint.");
expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(false);
expect(session.activeForegroundTurnId).toBe("test-turn");
});
test("fails Codex steering without an initialized client", async () => {
const session = createSession();
session.client = null;
session.connected = true;
await expect(session.steerTurn!("hello")).rejects.toThrow("Codex client not initialized");
});
test("fails Codex steering without a current thread", async () => {
const session = createSession();
session.currentThreadId = null;
session.client = createStub<CodexClientLike>({
request: vi.fn(async () => ({})),
});
await expect(session.steerTurn!("hello")).rejects.toThrow(
"Cannot steer Codex turn without an active thread",
);
});
test("fails Codex steering without an active foreground turn", async () => {
const session = createSession();
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: vi.fn(async () => ({})),
});
await expect(session.steerTurn!("hello")).rejects.toThrow(
"Cannot steer Codex turn without an active foreground turn",
);
});
test("fails Codex steering without an active app-server turn id", async () => {
const session = createSession();
session.activeAppServerTurnId = null;
session.client = createStub<CodexClientLike>({
request: vi.fn(async () => ({})),
});
await expect(session.steerTurn!("hello")).rejects.toThrow(
"Cannot steer Codex turn without an active app-server turn",
);
});
test("resolves Codex skill slash commands into app-server skill input", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
@@ -171,6 +171,7 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsRewindConversation: true,
supportsRewindFiles: false,
supportsRewindBoth: false,
supportsSteering: true,
};
const CODEX_MODES: AgentMode[] = [
@@ -2888,6 +2889,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeAppServerTurnId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: "fast" | null = null;
private planModeEnabled = false;
@@ -3481,6 +3483,13 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
private async buildEffectivePromptInput(prompt: AgentPromptInput): Promise<CodexPromptInput> {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
return slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
return runProviderTurn({
prompt,
@@ -3513,10 +3522,7 @@ export class CodexAppServerAgentSession implements AgentSession {
throw new Error("Codex client not initialized");
}
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
const effectivePrompt = await this.buildEffectivePromptInput(prompt);
if (this.currentThreadId) {
await this.ensureThreadLoaded();
@@ -3528,6 +3534,7 @@ export class CodexAppServerAgentSession implements AgentSession {
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
this.activeAppServerTurnId = null;
try {
this.logTurnStartSummary({
@@ -3539,9 +3546,15 @@ export class CodexAppServerAgentSession implements AgentSession {
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
const response = await this.client.request(
"turn/start",
turnStart.params,
TURN_START_TIMEOUT_MS,
);
this.rememberStartedAppServerTurn(response);
} catch (error) {
this.activeForegroundTurnId = null;
this.activeAppServerTurnId = null;
throw error;
}
@@ -3583,6 +3596,36 @@ export class CodexAppServerAgentSession implements AgentSession {
};
}
private rememberStartedAppServerTurn(response: unknown): void {
const record = toObjectRecord(response);
const turn = toObjectRecord(record?.turn);
this.activeAppServerTurnId = typeof turn?.id === "string" ? turn.id : null;
}
async steerTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<void> {
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
}
if (!this.currentThreadId) {
throw new Error("Cannot steer Codex turn without an active thread");
}
if (!this.activeForegroundTurnId) {
throw new Error("Cannot steer Codex turn without an active foreground turn");
}
if (!this.activeAppServerTurnId) {
throw new Error("Cannot steer Codex turn without an active app-server turn");
}
const effectivePrompt = await this.buildEffectivePromptInput(prompt);
const input = await this.buildUserInput(effectivePrompt);
await this.client.request("turn/steer", {
threadId: this.currentThreadId,
input,
expectedTurnId: this.activeAppServerTurnId,
});
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
@@ -3920,6 +3963,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.resolvedPermissionRequests.clear();
this.subscribers.clear();
this.activeForegroundTurnId = null;
this.activeAppServerTurnId = null;
if (this.client) {
await this.client.dispose();
}
@@ -4541,6 +4585,7 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
this.currentTurnId = parsed.turnId;
this.activeAppServerTurnId = parsed.turnId;
this.resetTurnTrackingState();
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
}
@@ -4578,6 +4623,7 @@ export class CodexAppServerAgentSession implements AgentSession {
});
}
this.activeForegroundTurnId = null;
this.activeAppServerTurnId = null;
this.resetTurnTrackingState();
}
@@ -1954,6 +1954,15 @@ function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): {
};
}
function isOpenCodeSessionTrackedByParent(
sessionId: string,
state: OpenCodeEventTranslationState,
): boolean {
return (
sessionId === state.sessionId || state.subAgentCallIdByChildSessionId?.has(sessionId) === true
);
}
function getOpenCodeSubAgentState(
callId: string,
state: OpenCodeEventTranslationState,
@@ -2438,7 +2447,7 @@ function appendOpenCodePermissionAsked(
state: OpenCodeEventTranslationState,
events: AgentStreamEvent[],
): void {
if (event.properties.sessionID !== state.sessionId) {
if (!isOpenCodeSessionTrackedByParent(event.properties.sessionID, state)) {
return;
}
const metadata = readOpenCodeRecord(event.properties.metadata);
@@ -387,6 +387,97 @@ describe("translateOpenCodeEvent", () => {
]);
});
it("forwards permission requests from linked OpenCode subagent sessions", () => {
const state = createState();
translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "part-subagent",
sessionID: "session-1",
messageID: "message-1",
type: "tool",
tool: "task",
callID: "call-subagent",
state: {
status: "running",
input: {
subagent_type: "explore",
description: "Explore external config",
},
},
},
},
},
state,
);
translateOpenCodeEvent(
{
type: "session.created",
properties: {
sessionID: "child-session-1",
info: {
id: "child-session-1",
parentID: "session-1",
},
},
},
state,
);
const result = translateOpenCodeEvent(
{
type: "permission.asked",
properties: {
id: "perm-child-1",
sessionID: "child-session-1",
permission: "external_directory",
patterns: ["/Users/example/.config/nvim"],
metadata: {
reason: "Need to inspect the requested config directory",
},
},
},
state,
);
expect(result).toEqual([
{
type: "permission_requested",
provider: "opencode",
request: {
id: "perm-child-1",
provider: "opencode",
name: "external_directory",
kind: "tool",
title: "Access external directory",
description:
"Need to inspect the requested config directory - Scope: /Users/example/.config/nvim",
input: {
patterns: ["/Users/example/.config/nvim"],
metadata: {
reason: "Need to inspect the requested config directory",
},
},
detail: {
type: "unknown",
input: {
permission: "external_directory",
patterns: ["/Users/example/.config/nvim"],
metadata: {
reason: "Need to inspect the requested config directory",
},
},
output: null,
},
actions: openCodePermissionActions,
},
},
]);
});
it("emits usage_updated after step-finish parts", () => {
const state = createState();
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"type": "module",
"scripts": {
@@ -31,12 +31,6 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
>
Changelog
</a>
<a
href="/cloud"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Cloud
</a>
<a
href="/docs/cli"
className="block text-muted-foreground hover:text-foreground transition-colors"
@@ -28,12 +28,6 @@ export function SiteHeader() {
>
Changelog
</a>
<a
href="/cloud"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cloud
</a>
<a
href="/download"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"